You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在NestJS/TypeScript自动化测试中模拟MinIO服务?

在NestJS自动化测试中替代MinIO的内存方案

背景

我正在开发一款基于NestJS的应用,用MinIO替代Amazon S3存储文档,通过Docker Compose部署,包含NestJS后端(集成MinIO客户端)、PostgreSQL数据库和MinIO服务器。

在自动化测试中,我选择用内存型SQLite替代PostgreSQL,而非模拟数据库函数——这个方案实现更简单,也不容易出错。现在我想对MinIO采取类似方案:要么让MinIO在内存中运行,要么模拟它的操作,让测试不需要依赖外部MinIO服务器。

待测试的相关代码

CloudStorageService

import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { Client } from 'minio';
import * as path from 'path';

export type CloudStorageConfigs = {
  endPoint: string;
  bucketName: string;
  port: number;
  useSSL: boolean;
  accessKey: string;
  secretKey: string;
};

@Injectable()
export class CloudStorageService {
  private readonly client: Client;

  constructor(private readonly configs: CloudStorageConfigs) {
    this.client = new Client(configs);
  }

  async save(file: Express.Multer.File[], directory?: string) {
    await Promise.all(file.map((file) => this.saveSingleFile(file, directory)));
    return true;
  }

  private async saveSingleFile(file: Express.Multer.File, directory?: string) {
    const filename = this.getUniqueFilename(file);
    const objectName = path.join(directory, filename);
    const buffer = Buffer.from(file.buffer);
    if (!(await this.client.bucketExists(this.configs.bucketName))) {
      await this.client.makeBucket(this.configs.bucketName);
    }
    await this.client.putObject(this.configs.bucketName, objectName, buffer);
    return true;
  }

  async list(directory: string) {
    return await new Promise((resolve, reject) => {
      const objects: string[] = [];
      const objectsStream = this.client.listObjectsV2(
        this.configs.bucketName,
        directory,
        false,
      );
      objectsStream.on('data', (obj) => {
        objects.push(obj.name || obj.prefix);
      });
      objectsStream.on('error', (err) => {
        reject(err);
      });
      objectsStream.on('end', () => {
        resolve(objects);
      });
    });
  }

  async delete(objects: string[]) {
    await this.client.removeObjects(this.configs.bucketName, objects);
    return true;
  }

  private getUniqueFilename(file: Express.Multer.File): string {
    if (!file?.originalname) {
      throw new UnprocessableEntityException('File not defined');
    }
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
    const tokens = file.originalname.split('.');
    let ext;
    if (!tokens || tokens.length < 2) {
      ext == '';
    } else {
      ext = '.' + tokens[tokens.length - 1];
    }
    const filename = `${uniqueSuffix}${ext}`;
    return filename;
  }
}

CloudStorageModule

import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import {
  CloudStorageConfigs,
  CloudStorageService,
} from './services/file/cloud-storage.service';

@Module({
  imports: [
    MulterModule.register({
      // dest: './uploads',
    }),
    ServeStaticModule.forRoot({ rootPath: join(process.cwd(), 'public') }),
  ],
})
export class CloudStorageModule {
  static forRoot(storageConfigs: CloudStorageConfigs) {
    return {
      module: CloudStorageModule,
      providers: [
        {
          provide: CloudStorageService,
          useValue: new CloudStorageService(storageConfigs),
        },
      ],
      exports: [CloudStorageService],
    };
  }
}

ImageModule

import { Module } from '@nestjs/common';
import { CloudStorageModule } from '../cloud-storage/cloud-storage.module';
import { MinioCongifs } from '../cloud-storage/configs/minio/minio.congifs';
import { ImageController } from './controller/image.controller';

@Module({
  imports: [
    CloudStorageModule.forRoot({
      endPoint: MinioCongifs.endpoint,
      port: MinioCongifs.port,
      useSSL: MinioCongifs.useSSL,
      accessKey: MinioCongifs.accessKey,
      secretKey: MinioCongifs.secretKey,
      bucketName: MinioCongifs.bucketName,
    }),
  ],
  controllers: [ImageController],
  providers: [],
})
export class ImageModule {}

测试模块生成函数

export async function getTestingModule(
  additionalMetadata?: ModuleMetadata,
): Promise<TestingModule> {
  return await Test.createTestingModule({
    imports: [
      ConfigModule.forRoot({ isGlobal: true }),
      TypeOrmModule.forFeature([ UserEntity ]),
      ConfigModule.forRoot({ isGlobal: true }),
      TypeOrmModule.forRoot({
        type: 'sqlite',
        database: ':memory:',
        autoLoadEntities: true,
        synchronize: true,
      }),
      PassportModule,
      JwtModule.register({
        secret: JWTConfigs.ACCESS_TOKEN_SECRET,
        signOptions: { expiresIn: JWTConfigs.ACCESS_TOKEN_EXPIRATION             },
      }),
      ...(additionalMetadata?.imports || []),
    ],
    providers: [
      AppService,
      AuthenticationService,
      EncryptionService,
      JwtStrategy,
      LocalStrategy,
      RefreshTokenRepository,
      TokenService,
      UserService,
      CloudStorageService,
      ...(additionalMetadata?.providers || []),
      { provide: APP_GUARD, useClass: JwtAuthenticationGuard },
      { provide: APP_GUARD, useClass: RolesGuard },
    ],
    controllers: [
      AppController,
      AuthenticationController,
      UserController,
      ImageController,
      ...(additionalMetadata?.controllers || []),
    ],
  }).compile();
}

可行方案

针对需求,有两种可靠的实现方式:

1. 使用MinIO内存模式

MinIO本身支持内存存储模式,完全在内存中运行,无需持久化到磁盘,非常适合测试场景。

实现步骤:

  • 在测试环境中启动内存版MinIO实例,可直接通过minio包配置启动,或用命令行启动内存模式:
    minio server /data --address :9000 --memory
    
  • 修改测试模块中CloudStorageService的配置,指向这个内存实例的地址、密钥等参数。

2. 模拟MinIO客户端(依赖注入替换)

直接模拟MinIO客户端的核心方法,无需启动任何外部服务,通过依赖注入替换真实服务。

实现步骤:

  • 创建模拟的CloudStorageService,用内存数据结构模拟存储逻辑:
    @Injectable()
    export class MockCloudStorageService {
      private storage = new Map<string, Buffer>();
      private buckets = new Set<string>();
    
      async save(file: Express.Multer.File[], directory?: string) {
        await Promise.all(file.map(file => this.saveSingleFile(file, directory)));
        return true;
      }
    
      private async saveSingleFile(file: Express.Multer.File, directory?: string) {
        const filename = this.getUniqueFilename(file);
        const objectName = path.join(directory || '', filename);
        const bucketName = 'test-bucket';
        if (!this.buckets.has(bucketName)) {
          this.buckets.add(bucketName);
        }
        this.storage.set(`${bucketName}/${objectName}`, Buffer.from(file.buffer));
        return true;
      }
    
      async list(directory: string) {
        const bucketName = 'test-bucket';
        return Array.from(this.storage.keys())
          .filter(key => key.startsWith(`${bucketName}/${directory}`))
          .map(key => key.replace(`${bucketName}/`, ''));
      }
    
      async delete(objects: string[]) {
        const bucketName = 'test-bucket';
        objects.forEach(obj => this.storage.delete(`${bucketName}/${obj}`));
        return true;
      }
    
      private getUniqueFilename(file: Express.Multer.File): string {
        if (!file?.originalname) {
          throw new UnprocessableEntityException('File not defined');
        }
        const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
        const tokens = file.originalname.split('.');
        let ext = '';
        if (tokens && tokens.length >= 2) {
          ext = '.' + tokens[tokens.length - 1];
        }
        return `${uniqueSuffix}${ext}`;
      }
    }
    
  • 在测试模块中替换真实服务:
    export async function getTestingModule(
      additionalMetadata?: ModuleMetadata,
    ): Promise<TestingModule> {
      return await Test.createTestingModule({
        // ...其他配置
        providers: [
          // ...其他服务
          { provide: CloudStorageService, useClass: MockCloudStorageService },
          // ...其他配置
        ],
      }).compile();
    }
    

方案对比

  • 内存MinIO:更贴近生产环境,能测试真实的MinIO客户端交互,适合集成测试。
  • 模拟服务:轻量快速,完全无外部依赖,适合单元测试。

可根据测试类型选择对应的方案。

内容的提问来源于stack exchange,提问作者user3788133

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.17 00:07:01