Skip to content

Commit

Permalink
Merge pull request #179 from mauroservienti/generic-host-support-1.1
Browse files Browse the repository at this point in the history
Generic host support - v1.1
  • Loading branch information
mauroservienti authored Oct 5, 2021
2 parents 8bcf1b7 + 2465c37 commit ae4a68d
Show file tree
Hide file tree
Showing 33 changed files with 604 additions and 97 deletions.
113 changes: 97 additions & 16 deletions README.md

Large diffs are not rendered by default.

53 changes: 40 additions & 13 deletions README.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ NServiceBus.IntegrationTesting allows testing end-to-end business scenarios, exe

## Disclaimer

NServiceBus.IntegrationTesting is not affiliated with Particular Software and thus is not officially supported. It's evolution stage doesn't make it production ready yet.
NServiceBus.IntegrationTesting is not affiliated with Particular Software and thus is not officially supported. It's evolution stage doesn't make it production ready yet.

## tl;dr

Expand Down Expand Up @@ -66,11 +66,11 @@ Defining an NServiceBus integration test is a multi-step process, composed of:

### Make sure endpoints configuration can be istantiated by tests

One of the goals of end-to-end testing an NServiceBus endpoints is to make sure that what gets tested is the real production code, not a copy of it crafted for the tests. The production endpoint configuration has to be used in tests. To make sure that the testing infrastructure can instantiate the endpoint configuration there are a couple of options, with many variations.
One of the goals of end-to-end testing a NServiceBus endpoint is to make sure that what gets tested is the real production code, not a copy of it crafted for the tests. The production endpoint configuration has to be used in tests. To make sure that the testing infrastructure can instantiate the endpoint configuration there are a couple of options, with many variations.

#### Inherit from EndpointConfiguration

It's possible to create a class that inherits from `EndpointConfiguration` and then use it in both the production endpoint and the tests. To make so that the testing infrastructure con automatically instante it, the class must have a parameterless constructor, like in the following snippet:
It's possible to create a class that inherits from `EndpointConfiguration` and then use it in both the production endpoint and the tests. To make so that the testing infrastructure can automatically instantiate it, the class must have a parameterless constructor, like in the following snippet:

snippet: inherit-from-endpoint-configuration

Expand All @@ -84,15 +84,42 @@ snippet: use-builder-class

### Define endpoints used in each test

To define an endpoint in tests a class inheriting from `EndpointConfigurationBuilder` needs to be created for each endpoint that needs to be used in a test. The best place to define such classes is as nested classes within the test class itself:
To define an endpoint in tests a class inheriting from `NServiceBus.AcceptanceTesting.EndpointConfigurationBuilder` needs to be created for each endpoint that needs to be used in a test. The best place to define such classes is as nested classes within the test class itself:

snippet: endpoints-used-in-each-test

The sample defines two endpoints, `MyServiceEndpoint` and `MyOtherServiceEndpoint`. `MyServiceEndpoint` uses the "inherit from EndpointConfiguration" approach to reference the production endpoint configuration. `MyOtherServiceEndpoint` uses the "builder class" by creating a custom endpoint template:
The sample defines two endpoints, `MyServiceEndpoint` and `MyOtherServiceEndpoint`. `MyServiceEndpoint` uses the "inherit from EndpointConfiguration" approach to reference the production endpoint configuration. `MyOtherServiceEndpoint` uses the "builder class" by inheriting from `NServiceBus.IntegrationTesting.EndpointTemplate`:

snippet: my-other-service-template

Using both approaches the endpoint configuration can be customized according to the environment needs, if needed.
The "builder class" approach allows specific modifications of the `EndpointConfiguration` for the tests. Although modifications should be kept to a minimum they are reasonable for a few aspects:

- Retries
- Retries should be reduced or even disabled for tests.
- Otherwise (with the default retry configuration) the IntegrationTests throw a "Some failed messages were not handled by the recoverability feature."-Exception because of a fixed 30 sec timeout in the `NServiceBus.AcceptanceTests.ScenarioRunner`.
- Cleaning up the queues via `PurgeOnStartup(true)`

#### Generic host support

NServiceBus endpoints can be [hosted using the generic host](https://docs.particular.net/samples/hosting/generic-host/). When using the generic host the endpoint lifecycle and configuration are controlled by the host. The following is a sample endpoint hosted using the generic host:

snippet: basic-generic-host-endpoint

> For more information about hosting NServiceBus using the generic host refer to the [official documentation](https://docs.particular.net/samples/hosting/generic-host/).
Before using generic host hosted endpoints with `NServiceBus.IntegrationTesting`, a minor change to the above snippet is required:

snippet: basic-generic-host-endpoint-with-config-previewer

The testing engine needs to access the endpoint configuration before it's initialized to register the needed tests behaviors. The creation of the `IHostBuilder` needs to be tweaked to invoke a callback delegate that the test engine injects at tests runtime.

Finally, the endpoint can be added to the scenario using the `WithGenericHostEndpoint` configuration method:

snippet: with-generic-host-endpoint

Be sure to pass to the method that creates the `IHostBuilder` the provided `Action<EndpointConfiguration>` parameter. If the endpoint is not configured correctly the following exception will be raised at test time:

> Endpoint \<endpointName\> is not correctly configured to be tested. Make sure to pass the EndpointConfiguration instance to the Action<EndpointConfiguration> provided by WithGenericHostEndpoint tests setup method.
### Define tests and completion criteria

Expand All @@ -102,9 +129,9 @@ Once endpoints are defined, the test choreography can be implemented, the first

snippet: scenario-skeleton

NOTE: The defined `Scenario` must use the `InterationScenarioContext` or a type that inherits from `InterationScenarioContext`.
NOTE: The defined `Scenario` must use the `IntegrationScenarioContext` or a type that inherits from `IntegrationScenarioContext`.

This tests aims to verify that when "MyService" sends a message to "MyOtherService" a reply is received by "MyService" and finally that a new saga instance is created. Use the `Define` static method to create a scenario and then add endpoints to the created scenario to append as many endpoints as needed for the scenario. Add a `Done` condition to specify when the test has to be considered completed adn finally invoke `Run` to exercise the `Scenario`.
This tests aims to verify that when "MyService" sends a message to "MyOtherService" a reply is received by "MyService" and finally that a new saga instance is created. Use the `Define` static method to create a scenario and then add endpoints to the created scenario to append as many endpoints as needed for the scenario. Add a `Done` condition to specify when the test has to be considered completed and finally invoke `Run` to exercise the `Scenario`.

#### Done condition

Expand All @@ -114,11 +141,11 @@ An end-to-end test execution can only be terminated by 3 events:
- the test times out
- there are unhandled exceptions

Unhandled exceptions are a sort of problem from the integration testing infrastructure perspecive as most of the times they'll result in messages being retried and eventually ending up in the error queue. based on this it's better to consider failed messages as part of the done condition:
Unhandled exceptions are a sort of problem from the integration testing infrastructure perspecive as most of the times they'll result in messages being retried and eventually ending up in the error queue. Based on this it's better to consider failed messages as part of the done condition:

snippet: simple-done-condition

Such a done condition has to be read has: "If there are one or more failed messages the test is done, proceed to evaulate the assertions". Obviously this is not enough. In the identified test case scenario the test is done when a saga is invoked (specifically is created, more on this later). A saga invokation can be expressed as a done condition in the following way:
Such a done condition has to be read as: "If there are one or more failed messages the test is done, proceed to evaulate the assertions". Obviously this is not enough. In the identified test case scenario the test is done when a saga is invoked (specifically is created, more on this later). A saga invokation can be expressed as a done condition in the following way:

snippet: complete-done-condition

Expand All @@ -131,7 +158,7 @@ In the defined callback it's possible to define one or more "when" conditions th

snippet: kick-off-choreography

The above code snippet makes so that when "MyServiceEndpoint" is started `AMessage` is sent. `When` has multiple overloads to accommodate many different scenarios.
The above code snippet makes so that when "MyServiceEndpoint" is started `AMessage` is sent. `When` has multiple overloads (including one with a condition-parameter) to accommodate many different scenarios.

### Assert on tests results

Expand All @@ -149,7 +176,7 @@ NServiceBus.IntegrationTesting provides a way to reschedule NServiceBus Timeouts

snippet: timeouts-reschedule

The above sample test shows how to inject an NServiceBus Timeout reschedule rule. When the production code, in this case the `ASaga` saga, schedules the `ASaga.MyTimeout` message, the registered NServiceBus Timeout reschedule rule will be invoked and a new delivery constraint is created, in this sample, to make so that the NServiceBus Timeout expires in 5 seconds insted of the default production value. The NServiceBus Timeout reschedule rule receives as arguments the current NServiceBus Timeout message and the current delivery constraint.
The above sample test shows how to inject an NServiceBus Timeout reschedule rule. When the production code, in this case the `ASaga` saga, schedules the `ASaga.MyTimeout` message, the registered NServiceBus Timeout reschedule rule will be invoked and a new delivery constraint is created, in this sample, to make so that the NServiceBus Timeout expires in 5 seconds instead of the default production value. The NServiceBus Timeout reschedule rule receives as arguments the current NServiceBus Timeout message and the current delivery constraint.

## Limitations

Expand All @@ -161,7 +188,7 @@ NServiceBus.IntegrationTesting is built on top of the NServiceBus.AcceptanceTest

### Assembly scanning setup

By default NServiceBus endpoints scan and load all assemblies found in the bin directory. This means that if more than one endpoints is loaded into the same process all endpoints will scan the same bin directory and all types related to NServiceBus, such as message handlers and/or sagas, are loaded by all endpoints. This can issues to endpoints running in end-to-end tests. It's suggested to configure the endpoint configuration to scan only a limited set of assemblies, and exclude those not related to the current endpoint. The assembly scanner configuration can be applied directly to the production endpoint configuration or as a customization in the test endpoint template setup.
By default NServiceBus endpoints scan and load all assemblies found in the bin directory. This means that if more than one endpoint is loaded into the same process all endpoints will scan the same bin directory and all types related to NServiceBus, such as message handlers and/or sagas, are loaded by all endpoints. This can issues to endpoints running in end-to-end tests. It's suggested to configure the endpoint configuration to scan only a limited set of assemblies, and exclude those not related to the current endpoint. The assembly scanner configuration can be applied directly to the production endpoint configuration or as a customization in the test endpoint template setup.

snippet: assembly-scanner-config

Expand Down
8 changes: 7 additions & 1 deletion src/MyService/ASaga.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using MyMessages.Messages;
using Microsoft.Extensions.Logging;
using MyMessages.Messages;
using NServiceBus;
using System;
using System.Threading.Tasks;
Expand All @@ -10,6 +11,11 @@ public class ASaga : Saga<ASagaData>,
IHandleMessages<CompleteASaga>,
IHandleTimeouts<ASaga.MyTimeout>
{
public ASaga(ILogger<ASaga> logger)
{
logger.LogInformation("ASaga instance created successfully");
}

public Task Handle(StartASaga message, IMessageHandlerContext context)
{
return RequestTimeout<MyTimeout>(context, DateTime.UtcNow.AddDays(10));
Expand Down
4 changes: 4 additions & 0 deletions src/MyService/MyService.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@

<ItemGroup>
<PackageReference Include="NServiceBus" Version="7.4.4" />
<PackageReference Include="NServiceBus.Extensions.Hosting" Version="1.1.0" />
<PackageReference Include="NServiceBus.Newtonsoft.Json" Version="2.2.0" />
<PackageReference Include="NServiceBus.RabbitMQ" Version="6.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="4.1.2" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.2.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.0" />
</ItemGroup>

<ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion src/MyService/MyServiceConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

namespace MyService
{
public class MyServiceConfiguration : EndpointConfiguration
class MyServiceConfiguration : EndpointConfiguration
{
public MyServiceConfiguration()
: base("MyService")
Expand Down
36 changes: 25 additions & 11 deletions src/MyService/Program.cs
Original file line number Diff line number Diff line change
@@ -1,22 +1,36 @@
using NServiceBus;
using Microsoft.Extensions.Hosting;
using NServiceBus;
using System;
using System.Threading.Tasks;
using Serilog;

namespace MyService
{
class Program
public class Program
{
static async Task Main(string[] args)
public static void Main(string[] args)
{
Console.Title = typeof(Program).Namespace;
CreateHostBuilder(args).Build().Run();
}

public static IHostBuilder CreateHostBuilder(string[] args, Action<EndpointConfiguration> configPreview = null)
{
var builder = Host.CreateDefaultBuilder(args);
builder.UseConsoleLifetime();

builder.UseSerilog((context, services, loggerConfiguration) => loggerConfiguration
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console());

var endpointConfiguration = new MyServiceConfiguration();
var endpointInstance = await Endpoint.Start(endpointConfiguration);
builder.UseNServiceBus(ctx =>
{
var config = new MyServiceConfiguration();
configPreview?.Invoke(config);
Console.WriteLine($"{typeof(Program).Namespace} started. Press any key to stop.");
Console.ReadLine();
return config;
});

await endpointInstance.Stop();
return builder;
}
}
}
}
10 changes: 1 addition & 9 deletions src/MySystem.AcceptanceTests/When_requesting_a_timeout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public async Task It_should_be_rescheduled_and_handled()
{
ctx.RegisterTimeoutRescheduleRule<ASaga.MyTimeout>((msg, delay) => new DoNotDeliverBefore(DateTime.UtcNow.AddSeconds(5)));
})
.WithEndpoint<MyServiceEndpoint>(behavior =>
.WithGenericHostEndpoint("MyService", configPreview => Program.CreateHostBuilder(new string[0], configPreview).Build(), behavior =>
{
behavior.When(session => session.Send("MyService", new StartASaga() {AnIdentifier = Guid.NewGuid()}));
})
Expand All @@ -42,13 +42,5 @@ public async Task It_should_be_rescheduled_and_handled()
Assert.False(context.HasFailedMessages());
Assert.False(context.HasHandlingErrors());
}

class MyServiceEndpoint : EndpointConfigurationBuilder
{
public MyServiceEndpoint()
{
EndpointSetup<EndpointTemplate<MyServiceConfiguration>>();
}
}
}
}
13 changes: 2 additions & 11 deletions src/MySystem.AcceptanceTests/When_sending_AMessage.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using MyMessages.Messages;
using MyOtherService;
using MyService;
using NServiceBus;
using NServiceBus.AcceptanceTesting;
Expand Down Expand Up @@ -30,9 +29,9 @@ public async Task AReplyMessage_is_received_and_ASaga_is_started()
{
var theExpectedIdentifier = Guid.NewGuid();
var context = await Scenario.Define<IntegrationScenarioContext>()
.WithEndpoint<MyServiceEndpoint>(behavior =>
.WithGenericHostEndpoint("MyService", configPreview => Program.CreateHostBuilder(new string[0], configPreview).Build(), behavior =>
{
behavior.When(session => session.Send(new AMessage() {AnIdentifier = theExpectedIdentifier}));
behavior.When(session => session.Send(new AMessage() { AnIdentifier = theExpectedIdentifier }));
})
.WithEndpoint<MyOtherServiceEndpoint>()
.Done(c => c.SagaWasInvoked<ASaga>() || c.HasFailedMessages())
Expand All @@ -47,14 +46,6 @@ public async Task AReplyMessage_is_received_and_ASaga_is_started()
Assert.False(context.HasHandlingErrors());
}

class MyServiceEndpoint : EndpointConfigurationBuilder
{
public MyServiceEndpoint()
{
EndpointSetup<EndpointTemplate<MyServiceConfiguration>>();
}
}

class MyOtherServiceEndpoint : EndpointConfigurationBuilder
{
public MyOtherServiceEndpoint()
Expand Down
12 changes: 2 additions & 10 deletions src/MySystem.AcceptanceTests/When_sending_CompleteASaga.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ public async Task ASaga_is_completed()
{
var theExpectedIdentifier = Guid.NewGuid();
var context = await Scenario.Define<IntegrationScenarioContext>()
.WithEndpoint<MyServiceEndpoint>(behavior =>
.WithGenericHostEndpoint("MyService", configPreview => Program.CreateHostBuilder(new string[0], configPreview).Build(), behavior =>
{
behavior.When(session =>
{
return session.Send("MyService", new StartASaga() {AnIdentifier = theExpectedIdentifier});
return session.SendLocal(new StartASaga() {AnIdentifier = theExpectedIdentifier});
});
behavior.When(condition: ctx =>
{
Expand All @@ -59,13 +59,5 @@ public async Task ASaga_is_completed()
Assert.False(context.HasFailedMessages());
Assert.False(context.HasHandlingErrors());
}

class MyServiceEndpoint : EndpointConfigurationBuilder
{
public MyServiceEndpoint()
{
EndpointSetup<EndpointTemplate<MyServiceConfiguration>>();
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="NServiceBus" Version="7.4.4" />
<PackageReference Include="NServiceBus.Extensions.Hosting" Version="1.1.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\MyMessages\MyMessages.csproj" />
<ProjectReference Include="..\NServiceBus.AssemblyScanner.Extensions\NServiceBus.AssemblyScanner.Extensions.csproj" />
</ItemGroup>

</Project>
30 changes: 30 additions & 0 deletions src/NServiceBus.IntegrationTesting.Tests.TestEndpoint/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Microsoft.Extensions.Hosting;

namespace NServiceBus.IntegrationTesting.Tests.TestEndpoint
{
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}

public static IHostBuilder CreateHostBuilder(string[] args)
{
var builder = Host.CreateDefaultBuilder(args);
builder.UseConsoleLifetime();

builder.UseNServiceBus(ctx =>
{
var config = new EndpointConfiguration("NServiceBus.IntegrationTesting.Tests.TestEndpoint");
config.UseTransport<LearningTransport>();
config.AssemblyScanner().ExcludeAssemblies("NServiceBus.IntegrationTesting.Tests.dll");
return config;
});

return builder;
}
}
}
Loading

0 comments on commit ae4a68d

Please sign in to comment.