docker 真是好,docker compose 更是不错。然而,在频繁的启动关闭中,有时候也很容易让人烦,比如很多测试需要启动一些像数据库之类的依赖,就不得不在 README 里提一句,跑测试前需要先 docker compose up -d 一下。虽然似乎很简单,但仍然有点麻烦,不免会让人在此栽跟着,连大佬也不能幸免,比如:
我发现通过 test containers 可以不用再手动去执行这些命令。
感觉上,test containers 就是一个全托管的 docker compose,即只需要写测试代码就好了,至于启动容器和销毁容器,不用再手动做了。当然,底层还是容器,可以在发起执行测试的命令之后,通过 docker desktop 看到测试容器正在启动中:
在测试运行结束后,可以看到容器被自动销毁:
这里分享一个完整的测试案例:应用程序启动了一个 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
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));
}
}