Jakiś czas temu pisałem o testach w kontekście API (tutaj). W tym poście postaram się opisać jak napisać podobne testy, ale gdy nasza aplikacja komunikuje się poprzez kolejkę. Pokaże to z wykorzystaniem biblioteki MassTransit.
Przykładowy konsument
Załóżmy, że mamy następującego konsumenta (consumer) – przyjmuje on jakieś zależności w konstruktorze, a w środku metody Consume publikuje jakąś wiadomość:
public class CustomMessageConsumer : IConsumer<CustomMessage>
{
private readonly ICustomClass _customClass;
public CustomMessageConsumer(ICustomClass customClass)
{
_customClass = customClass;
}
public async Task Consume(ConsumeContext<CustomMessage> context)
{
Console.WriteLine($"Processing {context.Message.Text}");
await context.Publish(
new OtherMessage { Text = $"{_customClass.DoSomething(context.Message.Text)}" }
);
}
}
Nasze wiadomości wyglądają następująco:
public class CustomMessage
{
public string Text { get; set; }
}
public class OtherMessage
{
public string Text { get; set; }
}
Przykładowy test
Test dla naszego konsumenta może wyglądać następująco:
public async Task CustomMessageConsumerTest()
{
var messageBus = new InMemoryTestHarness();
var customMessageConsumer = GetCustomMessageConsumer(messageBus);
await messageBus.Start();
try
{
await messageBus.InputQueueSendEndpoint.Send(new CustomMessage
{
Text = "test"
});
(await messageBus.Consumed.Any<CustomMessage>()).Should().BeTrue();
(await customMessageConsumer.Consumed.Any<CustomMessage>()).Should().BeTrue();
(await messageBus.Published.Any<OtherMessage>()).Should().BeTrue();
(await messageBus.Published.Any<Fault<CustomMessage>>()).Should().BeFalse();
}
finally
{
await messageBus.Stop();
}
}
Na początku tworzymy obiekt typu InMemoryTestHarness. Jest to klasa, która zachowuje się jak kolejka, ale działa w pamięci i służy do testów. Udostępnia ona metody pozwalające sprawdzić jakie wiadomości zostały opublikowane i skonsumowane. Następnie tworzymy naszego konsumenta (o tym więcej poniżej) i startujemy kolejkę. To tyle, jeśli chodzi o sekcję „przygotowanie” (arrange).
Jako „akcja” (act) w naszym przypadku będzie wysłanie wiadomości CustomMessage.
W sekcji „sprawdzenie” (assert) dokonujemy następujących sprawdzeń:
- wiadomość CustomMessage została skonsumowana przez naszą kolejkę,
- wiadomość CustomMessage została skonsumowana przez nasz CustomMessageConsumer,
- wiadomość OtherMessage została opublikowana,
- wiadomość Fault<CustomMessage> nie została opublikowana – czyli wiadomość CustomMessage została przetworzona poprawnie i nie pojawiły się żadne błędy.
Innymi rzeczami, jakie możemy sprawdzać w sekcji „sprawdzenie”, jest np. czy odpowiedni mock został wywołany (w przypadku testów jednostkowych) albo czy aplikacja ma odpowiedni stan (w przypadku testów e2e).
GetCustomMessageConsumer
W zależności od tego, czy chcemy przetestować naszego konsumenta przy pomocy testów jednostkowych, czy testów e2e, to ta metoda będzie wyglądała inaczej.
W przypadku testów jednostkowych może ona wyglądać tak:
private ConsumerTestHarness<CustomMessageConsumer> GetCustomMessageConsumer(
InMemoryTestHarness messageBus
)
{
var customClass = new Mock<ICustomClass>();
customClass.Setup(s => s.DoSomething(It.IsAny<string>())).Returns("test2");
return messageBus.Consumer(() => new CustomMessageConsumer(customClass.Object));
}
A w przypadku testów e2e może ona wyglądać tak:
private ConsumerTestHarness<CustomMessageConsumer> GetCustomMessageConsumer(
InMemoryTestHarness messageBus
)
{
var serviceProvider = GetServiceProvider();
return messageBus.Consumer(() => serviceProvider.GetService<CustomMessageConsumer>());
}
W metodzie GetServiceProvider tworzylibyśmy ServiceProvider według naszych potrzeb (np. z wykorzystaniem testowego Startup). Taki ServiceProvider może istnieć w kontekście:
- danej metody – dla każdego testu tworzymy nowy,
- danej klasy – tworzymy jeden na początku i wykorzystujemy go we wszystkich testach w danej klasie,
- kilku klas (np. przy użyciu Fixture, gdy używamy xUnit) – tworzymy jeden i wykorzystujemy go w wielu klasach.
Inne podejście do testów
Poprzedni test był z wykorzystaniem InMemoryTestHarness. Alternatywą do tego rozwiązania jest skonfigurowanie naszego MassTransit tak, aby zamiast zwykłej kolejki używał kolejki w pamięci (InMemory).
Czym to się różni od stworzenia InMemoryTestHarness? Główna różnica jest taka, że nie musimy wtedy tworzyć ręcznie naszego konsumenta i może on wykorzystać konfigurację taką samą, jaka jest na produkcji (np. retry policy).
Test może wtedy wyglądać tak:
public async Task CustomMessageConsumerTest()
{
var customClass = new Mock<ICustomClass>();
customClass
.Setup(x => x.DoSomething(It.IsAny<string>()))
.Throws<Exception>();
var serviceProvider = ConfigureServiceProvider(customClass.Object);
var messageBus = serviceProvider.GetService<IBusControl>();
await messageBus.StartAsync();
try
{
await messageBus.Publish(new CustomMessage
{
Text = "test"
});
await Task.Delay(TimeSpan.FromMilliseconds(100)).ConfigureAwait(false);
customClass.Verify(
x => x.DoSomething(It.IsAny<string>()),
Times.Exactly(6)
);
}
finally
{
await messageBus.StopAsync();
}
}
W tym teście, zamiast tworzyć messageBus, jest on pobierany z serviceProvider. Tutaj dodatkowo zarejestrowaliśmy interface ICustomClass jako mock, aby można było zrobić na nim sprawdzenia. Równie dobrze można ten krok pominąć i skorzystać z oryginalnych (produkcyjnych) klas.
Ciało metody ConfigureServiceProvider:
private static ServiceProvider ConfigureServiceProvider(ICustomClass customClass)
{
var startup = new TestStartup();
var serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton(customClass);
startup.ConfigureMassTransit(serviceCollection);
var serviceProvider = serviceCollection.BuildServiceProvider();
return serviceProvider;
}
Tutaj podobnie samo jak w poprzednim przypadku – możemy taki serviceProvider stworzyć dla danego testu, dla danej klasy albo dla wielu klas z wykorzystaniem Fixture.
Jeśli chodzi o metodę ConfigureMassTransit, w klasie Startup wygląda ona następująco:
public IServiceCollection ConfigureMassTransit(IServiceCollection services)
{
services.AddMassTransit(serviceCollectionConfigurator =>
{
serviceCollectionConfigurator.AddConsumer<CustomMessageConsumer>(
typeof(CustomMessageConsumerDefinition)
);
serviceCollectionConfigurator.AddConsumer<OtherMessageConsumer>(
typeof(OtherMessageConsumerDefinition)
);
ConfigureQueue(serviceCollectionConfigurator);
});
return services;
}
Metoda ConfigureQueue (w klasie Startup) wygląda tak:
protected virtual void ConfigureQueue(
IServiceCollectionBusConfigurator serviceCollectionConfigurator
)
{
serviceCollectionConfigurator.UsingRabbitMq((context, busCfg) =>
{
busCfg.ConfigureEndpoints(context);
busCfg.Host("rabbitmq://localhost", hostCfg =>
{
hostCfg.Username("guest");
hostCfg.Password("guest");
});
});
}
a w klasie TestStartup ta metoda wygląda tak:
protected override void ConfigureQueue(
IServiceCollectionBusConfigurator serviceCollectionConfigurator
)
{
serviceCollectionConfigurator.UsingInMemory((context, busCfg) =>
{
busCfg.ConfigureEndpoints(context);
});
}
Podsumowanie
Jeśli mamy aplikację (albo chcemy stworzyć), która komunikuję się poprzez kolejkę, nie powinniśmy mieć większych trudności ze stworzeniem odpowiednich testów. Dość łatwo powinniśmy być w stanie stworzyć testy jednostkowy czy e2e.
Pingback: dotnetomaniak.pl
Możliwość komentowania została wyłączona.