通过单元测试学习 EventSourceDB
学习通过 xUnit.NET 上的单元测试来使用 EventSourceDB
引言
关于 事件溯源 和 EventStoreDB,有很多白皮书和教程。 假设您已经阅读了这些内容,本文将介绍一些集成测试,以说明像 EventStoreDb 这样的原始事件溯源持久存储的运行时行为,为学习提供一些实际材料。
参考文献
背景
我第一次听到类似于事件溯源的东西是在 90 年代末的 "大数据" 和 Google 搜索数据库,它只允许 数据追加,不删除或更新物理数据存储,这对于以下业务案例很有好处:性能和扩展,以及以下业务背景:数字存储的丰富性。
EventStoreDB 或类似的东西不是你作为程序员不能没有的,但是,它可能会让你生活得更舒服,落入成功的陷阱,特别是对于 "事件溯源的 12 项变革性好处"。
使用代码
首先访问 EventStoreDB 的开发者门户,并安装一个 本地数据库服务器和 .NET 客户端 API(截至 2024 年 5 月为 v24.2)。 在 安装 之后,您应该能够看到以下内容
第一个测试
/// <summary>
/// Basic example from EventSourceDb tutorial on https://developers.eventstore.com/clients/grpc/#creating-an-event
/// </summary>
/// <returns></returns>
[Fact]
public async Task TestBasic()
{
var evt = new TestEvent
{
EntityId = Guid.NewGuid(),
ImportantData = "I wrote my first event!"
};
var eventData = new EventData(
Uuid.NewUuid(),
"TestEvent",
JsonSerializer.SerializeToUtf8Bytes(evt)
);
const string connectionString = "esdb://admin:changeit@localhost:2113?tls=true&tlsVerifyCert=false";
/// tls should be set to true. Different from the official tutorial as of 2024-05-05 on https://developers.eventstore.com/clients/grpc/#creating-an-event.
/// I used the zipped EventStoreDb installed in Windows machine, launched with `EventStore.ClusterNode.exe --dev`
var settings = EventStoreClientSettings.Create(connectionString);
using EventStoreClient client = new(settings);
string streamName = "some-stream";
IWriteResult writeResult = await client.AppendToStreamAsync(
streamName,
StreamState.Any,
new[] { eventData }
);
EventStoreClient.ReadStreamResult readStreamResult = client.ReadStreamAsync(
Direction.Forwards,
streamName,
StreamPosition.Start,
10);
ResolvedEvent[] events = await readStreamResult.ToArrayAsync();
string eventText = System.Text.Encoding.Default.GetString(events[0].Event.Data.ToArray());
TestEvent eventObj = JsonSerializer.Deserialize<TestEvent>(eventText);
Assert.Equal("I wrote my first event!", eventObj.ImportantData);
}
正如您所看到的,官方教程在开箱即用时不起作用,至少在 EventSourceDb Windows 版本中是这样。 因此,在进行了第一个读/写操作测试之后,我更有信心去探索更多内容。
备注/提示
- 本文中提出的测试用例/事实实际上并不是单元测试,因为我只是使用构建在单元测试框架上的测试套件来探索 EventSourceDB 在学习期间的功能。 然而,这种测试套件在商业应用程序开发中可能很有用,因为您可以拥有一个简单的 测试平台,以便在您的应用程序代码中出现问题时进行探索和调试,并且相应的调用堆栈可能涉及外部依赖项,例如 EventSourceDb。
第一个测试套件
自 EventSourceDb v21 以来,API 协议已从 "RESTful" Web API 更改为 gRPC。 通常建议重用 gRPC 连接。 因此,为 IClassFixture 制作了 EventStoreClientFixture。
public class EventStoreClientFixture : IDisposable
{
// todo: What is the best way to clear an event store for unit tests? https://github.com/EventStore/EventStore/issues/1328
public EventStoreClientFixture()
{
IConfigurationRoot config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build();
string eventStoreConnectionString = config.GetConnectionString("eventStoreConnection");
var settings = EventStoreClientSettings.Create(eventStoreConnectionString);
Client = new(settings);
}
public EventStoreClient Client { get; private set; }
#region IDisposable pattern
bool disposed = false;
void Dispose(bool disposing)
{
if (!disposed)
{
if (disposing)
{
Client.Dispose();
}
disposed = true;
}
}
public void Dispose()
{
Dispose(true);
}
#endregion
}
fixture 代码将由 xUnit.NET 运行时为从 IClassFixture<EventStoreClientFixture> 继承的每个测试类执行一次,因此同一类中的测试用例可以共享相同的 EventStoreClient 或 gRPC 连接。
但是,请记住,使用 xUnit,每个测试/事实都在测试类的新实例中执行,也就是说,如果一个测试类包含 10 个事实,则该类可能会被实例化 10 次。
public class BasicFacts : IClassFixture<EventStoreClientFixture>
{
public BasicFacts(EventStoreClientFixture fixture)
{
eventStoreClient = fixture.Client; // all tests here shared the same client connection
}
readonly EventStoreClient eventStoreClient;
[Fact]
public async Task TestBackwardFromEnd()
{
string importantData = "I wrote my test with fixture " + DateTime.Now.ToString("yyMMddHHmmssfff");
var evt = new TestEvent
{
EntityId = Guid.NewGuid(),
ImportantData = importantData,
};
var eventData = new EventData(
Uuid.NewUuid(),
"testEvent", //The name of the event type. It is strongly recommended that these use lowerCamelCase, if projections are to be used.
JsonSerializer.SerializeToUtf8Bytes(evt), // The raw bytes of the event data.
null, // The raw bytes of the event metadata.
"application/json" // The Content-Type of the EventStore.Client.EventData.Data. Valid values are 'application/json' and 'application/octet-stream'.
);
string streamName = "some-stream2";
IWriteResult writeResult = await eventStoreClient.AppendToStreamAsync(
streamName,
StreamState.Any,
new[] { eventData }
);
EventStoreClient.ReadStreamResult readStreamResult = eventStoreClient.ReadStreamAsync(
Direction.Backwards,
streamName,
StreamPosition.End,
10);
Assert.Equal(TaskStatus.WaitingForActivation, readStreamResult.ReadState.Status);
ResolvedEvent[] events = await readStreamResult.ToArrayAsync();
string eventText = System.Text.Encoding.Default.GetString(events[0].Event.Data.ToArray());
TestEvent eventObj = JsonSerializer.Deserialize<TestEvent>(eventText);
Assert.Equal(importantData, eventObj.ImportantData); // so the first in events returned is the latest in the DB side.
}
...
...
...
正如您从运行中看到的那样,初始连接到本地托管的 EventSourceDb 服务器可能需要 2 秒以上,后续调用很快。
测试负面案例
对于现实世界的应用程序,仅仅编译代码并使功能特性正常工作还远远不足以向客户提供业务价值。 为了进行防御性编程和建立基本防线以提供良好的用户体验,主动测试负面案例非常重要。
/// <summary>
/// Test with a host not existing
/// https://learn.microsoft.com/en-us/aspnet/core/grpc/deadlines-cancellation
/// </summary>
/// <returns></returns>
[Fact]
public async Task TestUnavailableThrows()
{
var evt = new TestEvent
{
EntityId = Guid.NewGuid(),
ImportantData = "I wrote my first event!"
};
var eventData = new EventData(
Uuid.NewUuid(),
"TestEvent",
JsonSerializer.SerializeToUtf8Bytes(evt)
);
const string connectionString = "esdb://admin:changeit@localhost:2000?tls=true&tlsVerifyCert=false"; // this connection is not there on port 2000
var settings = EventStoreClientSettings.Create(connectionString);
using EventStoreClient client = new(settings);
string streamName = "some-stream";
var ex = await Assert.ThrowsAsync<Grpc.Core.RpcException>(() => client.AppendToStreamAsync(
streamName,
StreamState.Any,
new[] { eventData }
));
Assert.Equal(Grpc.Core.StatusCode.Unavailable, ex.StatusCode);
}
/// <summary>
/// Simulate a slow or disrupted connection to trigger error.
/// </summary>
/// <returns></returns>
[Fact]
public async Task TestDeadlineExceededThrows()
{
var evt = new TestEvent
{
EntityId = Guid.NewGuid(),
ImportantData = "I wrote my first event!"
};
var eventData = new EventData(
Uuid.NewUuid(),
"TestEvent",
JsonSerializer.SerializeToUtf8Bytes(evt)
);
string streamName = "some-stream";
var ex = await Assert.ThrowsAsync<Grpc.Core.RpcException>(() => eventStoreClient.AppendToStreamAsync(
streamName,
StreamState.Any,
new[] { eventData },
null,
TimeSpan.FromMicroseconds(2) // set deadline very short to trigger DeadlineExceeded. This could happen due to network latency or TCP/IP's nasty nature.
));
Assert.Equal(Grpc.Core.StatusCode.DeadlineExceeded, ex.StatusCode);
}
/// <summary>
///
/// </summary>
/// <returns></returns>
[Fact]
public async Task TestReadNotExistingThrows()
{
EventStoreClient.ReadStreamResult readStreamResult = eventStoreClient.ReadStreamAsync(
Direction.Backwards,
"NotExistingStream",
StreamPosition.End,
10);
Assert.Equal(TaskStatus.WaitingForActivation, readStreamResult.ReadState.Status);
var ex = await Assert.ThrowsAsync<EventStore.Client.StreamNotFoundException>(async () => { var rs = await readStreamResult.ToArrayAsync(); });
Assert.Contains("not found", ex.Message);
}
熟悉此类错误场景将帮助您在应用程序代码中应用防御性编程并整体设计容错能力。
压力测试
当然,单元测试框架并不是一个非常好的压力测试或基准测试的测试平台,但是,它足以粗略了解一个“事实”可以运行的速度。 因此,如果相同的“事实”变得明显变慢,则可能表明某些功能实现正在发生错误。
以下事实实际上不是单元测试或集成测试,以探索 EventSourceDB 的基本性能。
[Fact]
public async Task TestBackwardFromEndWriteOnly_100()
{
for (int i = 0; i < 100; i++)
{
string importantData = "I wrote my test with fixture " + DateTime.Now.ToString("yyMMddHHmmssfff");
var evt = new TestEvent
{
EntityId = Guid.NewGuid(),
ImportantData = importantData,
};
var eventData = new EventData(
Uuid.NewUuid(),
"testEventStress",
JsonSerializer.SerializeToUtf8Bytes(evt),
null,
"application/json"
);
string streamName = "some-streamStress";
IWriteResult writeResult = await eventStoreClient.AppendToStreamAsync(
streamName,
StreamState.Any,
new[] { eventData }
);
Assert.True(writeResult.LogPosition.CommitPosition > 0);
}
}
备注
- 如果涉及某些 CI/CD 管道,您可能希望从管道中排除这些测试用例。
关注点
EventStoreDb 使用 long
(64 位有符号) 和 ulong
(64 位无符号),看看 JavaScript 客户端如何处理并克服 53 位精度限制将会很有趣。 请继续关注,在接下来的几周内,我可能会添加更多关于事件溯源和 EventStoreDb 的文章
- 与 EventSourceDb 对话的 TypeScript 代码
- 通过事件溯源进行审计追踪
- 通过事件溯源进行时间旅行
这些文章将使用集成测试套件来保护应用程序代码并说明涉及 EventSourceDB 的功能特性的运行时行为。
参考文献