docker 真是好,docker compose 更是不错。然而,在频繁的启动关闭中,有时候也很容易让人烦,比如很多测试需要启动一些像数据库之类的依赖,就不得不在 README 里提一句,跑测试前需要先 docker compose up -d 一下。虽然似乎很简单,但仍然有点麻烦,不免会让人在此栽跟着,连大佬也不能幸免,比如:

1725535217800 e822f1f3 4cff 4637 89cf c86f0627a8f5

我发现通过 test containers 可以不用再手动去执行这些命令。

感觉上,test containers 就是一个全托管的 docker compose,即只需要写测试代码就好了,至于启动容器和销毁容器,不用再手动做了。当然,底层还是容器,可以在发起执行测试的命令之后,通过 docker desktop 看到测试容器正在启动中:

1725535413337 aaef9ee4 412a 47cc 8d93 3cf37ededfef

在测试运行结束后,可以看到容器被自动销毁:

1725535512470 cbb08a38 e4c0 46f4 b81b 22e4cedbd57f

这里分享一个完整的测试案例:应用程序启动了一个 pulsar 消费者,用来消费 pulsar 上某一主题上的事件消息,从而用来更新用户最近和系统互动的时间。要测试这种场景,就可以让测试跑起来之前,通过 test containers 运行一个 pulsar 服务,然后就可以在测试代码中使用真正的 pulsar 消费者,避免写一堆模拟客户端和消费者等等。

以 dotnet core 应用举例,首先需要写一个 TestingBase 类,做一些测试生命周期管理,并将使用 test containers 启动 pulsar 服务的代码放在其中:

java public abstract class TestingFixtureBase(string databaseName) { private readonly PulsarContainer _pulsar = new PulsarBuilder().WithImage(apachepulsar/pulsar:2.10.0).Build();

protected abstract IServiceProvider Services { get; }

protected IHostBuilder HostBuilder { get; private set; }

protected async Task Initialize()
{
    await _pulsar.StartAsync();

    HostBuilder = Program.CreateHostBuilder([]);
    HostBuilder.UseEnvironment(local);
    HostBuilder.UseSerilog();

    ConfigureConfiguration(HostBuilder);
    HostBuilder.ConfigureServices((hostContext, services) =>
    {
        var dbConfig = hostContext.Configuration.Get<DatabaseConfiguration>()!;

        RecreateTestDatabase(dbConfig.Host, dbConfig.Username, dbConfig.Password, dbConfig.DatabaseName);
    });
}

private void RecreateTestDatabase(string host, string username, string password, string databaseName)
{
    // 创建连接字符串时不要指定数据库名称,否则如果数据库不存在将抛出异常。
    var connectionString = new MySqlConnectionStringBuilder
    {
        Server = host,
        UserID = username,
        Password = password,
        Port = 3306,
        // 在 GitHub Actions 中运行时需要
        SslMode = MySqlSslMode.None,
    };

    using var connection = new MySqlConnection(connectionString.ConnectionString);
    connection.Open();

    using var dropCommand = new MySqlCommand($DROP DATABASE IF EXISTS {databaseName}, connection);
    dropCommand.ExecuteNonQuery();

    using var createCommand = new MySqlCommand($CREATE DATABASE {databaseName}, connection);
    createCommand.ExecuteNonQuery();
}

private void ConfigureConfiguration(IHostBuilder hostBuilder)
{
    hostBuilder.ConfigureAppConfiguration((_, config) =>
    {
        var databaseHost = Environment.GetEnvironmentVariable(DATABASE_HOST) ?? localhost;
        config.AddInMemoryCollection(new Dictionary<string, string?>
        {
            [DATABASE_HOST] = databaseHost,
            [DATABASE_PORT] = 3306,
            [DATABASE_USERNAME] = root,
            [DATABASE_PASSWORD] = localdb123,
            [DATABASE_NAME] = databaseName,
            [DATABASE_SSL_MODE] = nameof(MySqlSslMode.None),
            [DATABASE_IAM_AUTHENTICATION_ENABLED] = false,
            [DATABASE_CONNECTION_IDLE_TIMEOUT] = 3600,
            [DATABASE_CONNECTION_LIFETIME] = 3600,
            [PULSAR_CLIENT_ID] = fake-client-id,
            [PULSAR_CLIENT_SECRET] = fake-client-secret,
            [PULSAR_SERVICE_URL] = _pulsar.GetBrokerAddress(),
            [TOPIC] = persistent://public/default/your-topic-name,
            [SUBSCRIPTION_NAME] = your-sub-name,
        });
    });
}

protected async Task Dispose()
{
    var dbContext = Services.CreateScope().ServiceProvider.GetRequiredService<ApplicationDbContext>();
    await dbContext.Database.EnsureDeletedAsync();
    await _pulsar.DisposeAsync().AsTask();
}

}

然后需要创建一个类继承自上面的 TestingBase,并实现 IAsyncLifetime 类,写一些测试通用的代码,用来做测试夹具:

csharp public class BackgroundJobFixture() : TestingFixtureBase(background_jobs_tests), IAsyncLifetime { private IServiceProvider _serviceProvider;

protected override IServiceProvider Services => _serviceProvider;

public IHost Host;

public async Task InitializeAsync()
{
    await Initialize();

    Host = HostBuilder.Build();

    _serviceProvider = Host.Services;

    var migrator = _serviceProvider.GetRequiredService<DatabaseMigrator>();
    migrator.ExecuteMigrations(true);
}

public Task DisposeAsync() => Dispose();

[CollectionDefinition(background_job_scenarios, DisableParallelization = false)]
public class BackgroundJobsScenarioCollection : ICollectionFixture<BackgroundJobFixture>;

[Collection(background_job_scenarios)]
public abstract class BackgroundJobsScenarioContext : IAsyncLifetime
{
    private readonly IServiceScope _serviceScope;
    private readonly IServiceProvider _serviceProvider;

    protected BackgroundJobsScenarioContext(BackgroundJobFixture fixture)
    {
        _serviceScope = fixture.Services.CreateScope();
        _serviceProvider = _serviceScope.ServiceProvider;

    }

    protected T GetService<T>() where T : class => _serviceProvider.GetRequiredService<T>();

    public Task InitializeAsync()
    {
        return Task.CompletedTask;
    }

    public Task DisposeAsync()
    {
        _serviceScope.Dispose();
        return Task.CompletedTask;
    }
}

}

最后,测试可能像是长这样:

csharp public class EventListenerJobEnd2EndTests : BackgroundJobFixture.BackgroundJobsScenarioContext, IAsyncLifetime { private readonly ApplicationDbContext _applicationDbContext; private readonly IPulsarClient _client; private readonly IProducer _producer; private readonly IHost _host;

public EventListenerJobEnd2EndTests(BackgroundJobFixture fixture) : base(fixture)
{
    _host = fixture.Host;
    _applicationDbContext = GetService<ApplicationDbContext>();
    var configuration = GetService<IConfiguration>();

    _client = PulsarClient.Builder()
        .ServiceUrl(new Uri(configuration[PULSAR_SERVICE_URL]!))
        .Build();

    _producer = _client.NewProducer(Schema.String)
        .Topic(configuration[TOPIC]!)
        .Create();
}

public new async Task DisposeAsync()
{
    await _producer.DisposeAsync();
    await _client.DisposeAsync();

    await base.DisposeAsync();
}

[Fact]
public async Task Should_ProcessEvent_WhenEventComes()
{
    // arrange
    var user = new UserBuilder().CreateValid().User;
    _applicationDbContext.Users.Add(user);
    await _applicationDbContext.SaveChangesAsync();

    await _host.StartAsync();

    var event
        = (V1EnvelopeCloudEventBuilder.Create().WithPublicUserId(user.PublicId).Generate());

    // act
    _ = await _producer.Send(JsonSerializer.Serialize(event)).ConfigureAwait(true);

    // 等待事件被处理。
    await Task.Delay(2000);

    // assert
    var actual = _applicationDbContext.ApplicationUserActivities.FirstOrDefault(x => x.UserId == user.Id);
    actual.Should().NotBeNull();
    actual!.LastActivity.Should().BeCloseTo(loyaltyEvent.Data.Timestamp, TimeSpan.FromSeconds(1));
}

}