Skip to content

Commit 702fd66

Browse files
authored
feat+: add spec-like test driver (#13)
1 parent 7b8e4ff commit 702fd66

File tree

11 files changed

+275
-72
lines changed

11 files changed

+275
-72
lines changed

.github/README.md

Lines changed: 94 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,15 @@ License](https://img.shields.io/github/license/ArwynFr/dotnet-integration-testin
1010

1111
## Installation
1212

13-
dotnet add package ArwynFr.IntegrationTesting
13+
```shell
14+
dotnet new classlib -n MyTestProject
15+
```
16+
17+
```shell
18+
dotnet add package ArwynFr.IntegrationTesting
19+
dotnet add pacakge Microsoft.NET.Test.Sdk
20+
dotnet add package xunit.runner.visualstudio
21+
```
1422

1523
## Usage
1624

@@ -23,53 +31,100 @@ output, so you get them in the test output in case of failure. It also
2331
overwrites the application configuration with values from user secrets
2432
and environement variables.
2533

26-
public class MyTest : IntegrationTestBase<Program>
34+
```cs
35+
public class MyTest : IntegrationTestBase<Program>
36+
{
37+
public MyTest(ITestOutputHelper output) : base(output) { }
38+
39+
[Fact]
40+
public async Task OnTest()
2741
{
28-
public MyTest(ITestOutputHelper output) : base(output) { }
42+
// Call system under test
43+
var response = await Client.GetFromJsonAsync<OrderDto>($"/order");
44+
45+
response.Should().HaveValue();
46+
}
47+
48+
// Override a service with fake implementation in the tested app
49+
protected override void ConfigureAppServices(IServiceCollection services)
50+
=> services.AddSingleton<IMyService, FakeService>();
51+
}
52+
```
53+
54+
### EntityFrameworkCore integration
55+
56+
```cs
57+
public class TestBaseDb : IntegrationTestBase<Program, MyDbContext>
58+
{
59+
public TestBaseDb(ITestOutputHelper output) : base(output) { }
2960

30-
[Fact]
31-
public async Task OnTest()
32-
{
33-
// Call system under test
34-
var response = await Client.GetFromJsonAsync<OrderDto>($"/order");
61+
[Fact]
62+
public async Task OnTest()
63+
{
64+
// Access the injected dbcontext
65+
var value = await Database.Values
66+
.Where(val => val.Id == 1)
67+
.Select(val => val.Result)
68+
.FirstOrDefaultAsync();
3569

36-
response.Should().HaveValue();
37-
}
70+
// Call system under test
71+
var result = await Client.GetFromJsonAsync<int>("/api/value/1");
3872

39-
// Override a service with fake implementation in the tested app
40-
protected override void ConfigureAppServices(IServiceCollection services)
41-
=> services.AddSingleton<IMyService, FakeService>();
73+
result.Should().Be(value + 1);
4274
}
4375

44-
### EntityFrameworkCore integration
76+
// Create and drop a database for every test execution
77+
protected override IDatabaseTestStrategy<Context> DatabaseTestStrategy
78+
=> IDatabaseTestStrategy<MyDbContext>.DatabasePerTest;
79+
80+
// Configure EFcore with a random database name
81+
protected override void ConfigureDbContext(DbContextOptionsBuilder builder)
82+
=> builder.UseSqlite($"Data Source={Guid.NewGuid()}.sqlite");
83+
}
84+
```
85+
86+
### Fluent specification-based testing
87+
88+
```cs
89+
// Actual code redacted for brievty
90+
// Write a test driver that implements specifications:
91+
private class MySpecDriver(MyDbContext dbContext, HttpClient client) : TestDriverBase<SpecDriver>
92+
{
93+
// Arranges
94+
public async Task ThereIsEntityWithName(string name) { }
95+
public async Task ThereIsNoEntityWithName(string name) { }
96+
97+
// Acts
98+
public async Task ListAllEntities() { }
99+
public async Task FindEntityWithName(string name) { }
100+
public async Task CreateEntity(EntityDetails payload) { }
101+
102+
// Asserts
103+
public async Task ResultShouldBe(string name) { }
104+
public async Task DetailsCountShouldBe(int number) { }
105+
}
106+
107+
public class MySpecTest(ITestOutputHelper output) : TestBaseDb
108+
{
109+
// Write fluent specifiation test:
110+
[Theory, InlineData("ArwynFr")]
111+
public async Task OnTest(string name)
112+
{
113+
await Services.GetRequiredService<MySpecDriver>()
114+
.Given(x => x.ThereIsEntityWithName(name))
115+
.When(x => x.FindEntityWithName(name))
116+
.Then(x => x.ResultShouldBe(name))
117+
.ExecuteAsync();
118+
}
45119

46-
public class TestBaseDb : IntegrationTestBase<Program, MyDbContext>
120+
// Configure DI library
121+
protected override void ConfigureAppServices(IServiceCollection services)
47122
{
48-
public TestBaseDb(ITestOutputHelper output) : base(output) { }
49-
50-
[Fact]
51-
public async Task OnTest()
52-
{
53-
// Access the injected dbcontext
54-
var value = await Database.Values
55-
.Where(val => val.Id == 1)
56-
.Select(val => val.Result)
57-
.FirstOrDefaultAsync();
58-
59-
// Call system under test
60-
var result = await Client.GetFromJsonAsync<int>("/api/value/1");
61-
62-
result.Should().Be(value + 1);
63-
}
64-
65-
// Create and drop a database for every test execution
66-
protected override IDatabaseTestStrategy<Context> DatabaseTestStrategy
67-
=> IDatabaseTestStrategy<MyDbContext>.DatabasePerTest;
68-
69-
// Configure EFcore with a random database name
70-
protected override void ConfigureDbContext(DbContextOptionsBuilder builder)
71-
=> builder.UseSqlite($"Data Source={Guid.NewGuid()}.sqlite");
123+
base.ConfigureAppServices(services);
124+
services.AddSingleton(_ => new MySpecDriver(Database, Client));
72125
}
126+
}
127+
```
73128

74129
## Contributing
75130

.github/USAGE.md

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ application behaves as expected.
2525
Simply extend the `IntegrationTestBase<TEntryPoint>` class and provide
2626
the entrypoint class you want to test:
2727

28-
public class MyTest : IntegrationTestBase<Program>
29-
{
30-
public MyTest(ITestOutputHelper output) : base(output) { }
31-
}
28+
```cs
29+
public class MyTest : IntegrationTestBase<Program>
30+
{
31+
public MyTest(ITestOutputHelper output) : base(output) { }
32+
}
33+
```
3234

3335
## Arranging the tested application
3436

@@ -59,23 +61,25 @@ Singleton lifetime. Accessing the DI container outside of the client
5961
call will return an unscoped provider as scopes are created and disposed
6062
by the framework for each request.
6163

62-
// Override a service with custom implementation in the tested app
63-
protected override void ConfigureAppServices(IServiceCollection services)
64-
=> services.AddSingleton<IMyService, FakeService>();
64+
```cs
65+
// Override a service with custom implementation in the tested app
66+
protected override void ConfigureAppServices(IServiceCollection services)
67+
=> services.AddSingleton<IMyService, FakeService>();
6568

66-
[Fact]
67-
public async Task OnTest()
68-
{
69-
var expected = 3;
69+
[Fact]
70+
public async Task OnTest()
71+
{
72+
var expected = 3;
7073

71-
// Access the injected service from the test code
72-
var service = Services.GetRequiredService<IMyService>();
73-
service.SetValue(expected);
74+
// Access the injected service from the test code
75+
var service = Services.GetRequiredService<IMyService>();
76+
service.SetValue(expected);
7477

75-
var response = await Client.GetFromJsonAsync<int>($"/value");
78+
var response = await Client.GetFromJsonAsync<int>($"/value");
7679

77-
response.Should().Be(expected);
78-
}
80+
response.Should().Be(expected);
81+
}
82+
```
7983

8084
## Accessing the tested application
8185

@@ -84,14 +88,14 @@ You can access the tested application using the `Client` property which
8488
returns a pseudo `HttpClient` created by `WebApplicationFactory`. You
8589
access your application like a client application would:
8690

87-
<!-- -->
88-
89-
[Fact]
90-
public async Task OnTest()
91-
{
92-
var response = await Client.GetFromJsonAsync<OrderDto>($"/order");
93-
response.Should().HaveValue();
94-
}
91+
```cs
92+
[Fact]
93+
public async Task OnTest()
94+
{
95+
var response = await Client.GetFromJsonAsync<OrderDto>($"/order");
96+
response.Should().HaveValue();
97+
}
98+
```
9599

96100
Configuration
97101
This property grants you acces to the `IConfiguration` values that are
@@ -131,8 +135,10 @@ context instance will be generated per test and injected in your target
131135
app as a singleton. You can access the same context instance in your
132136
test through the `Database` property.
133137

134-
protected override void ConfigureDbContext(DbContextOptionsBuilder builder)
135-
=> builder.UseSqlite($"Data Source=test.sqlite");
138+
```cs
139+
protected override void ConfigureDbContext(DbContextOptionsBuilder builder)
140+
=> builder.UseSqlite($"Data Source=test.sqlite");
141+
```
136142

137143
The base class also exposes a `DatabaseTestStrategy` property that
138144
allows you to customize the test behavior regarding the database. You
@@ -164,12 +170,12 @@ database in parallel. Otherwise the tests will try to access the same
164170
database concurrently and will fail to drop it while other tests are
165171
running:
166172

167-
<!-- -->
168-
169-
protected override IDatabaseTestStrategy<Context> DatabaseTestStrategy
170-
=> IDatabaseTestStrategy<Context>.DatabasePerTest;
173+
```cs
174+
protected override IDatabaseTestStrategy<Context> DatabaseTestStrategy
175+
=> IDatabaseTestStrategy<Context>.DatabasePerTest;
171176

172-
protected override void ConfigureDbContext(DbContextOptionsBuilder builder)
173-
=> builder.UseSqlite($"Data Source={Guid.NewGuid()}.sqlite");
177+
protected override void ConfigureDbContext(DbContextOptionsBuilder builder)
178+
=> builder.UseSqlite($"Data Source={Guid.NewGuid()}.sqlite");
179+
```
174180

175181
This beahvior WILL drop your database after each test !

.github/workflows/integration.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ jobs:
6666
with:
6767
files: .
6868
dot: true
69+
config_file: ./markdownlint.yaml
6970

7071
not-outdated:
7172
runs-on: ubuntu-latest

markdownlint.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
default: true
2+
MD046:
3+
style: fenced
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using Microsoft.AspNetCore.Http.HttpResults;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.EntityFrameworkCore;
4+
5+
namespace ArwynFr.IntegrationTesting.Tests.Target;
6+
7+
[ApiController]
8+
public class DummyController : ControllerBase
9+
{
10+
private readonly DummyDbContext _dbContext;
11+
12+
public DummyController(DummyDbContext dbContext) => _dbContext = dbContext;
13+
14+
[HttpGet("/api/entities/{name}")]
15+
public async Task<Results<Ok<string>, NotFound<string>>> ExecuteAsync(string name)
16+
{
17+
var value = await _dbContext.Entities.FirstOrDefaultAsync(entity => entity.Name == name);
18+
if (value == null) { return TypedResults.NotFound("Not found"); }
19+
return TypedResults.Ok(value.Name);
20+
}
21+
}

src/ArwynFr.IntegrationTesting.Tests.Target/DummyDbContext.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ public class DummyDbContext : DbContext
88
{
99
public DummyDbContext(DbContextOptions options) : base(options) => Expression.Empty();
1010

11+
public DbSet<DummyEntity> Entities => Set<DummyEntity>();
12+
1113
protected override void OnModelCreating(ModelBuilder modelBuilder)
1214
=> modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly);
1315
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace ArwynFr.IntegrationTesting.Tests.Target;
2+
3+
public class DummyEntity
4+
{
5+
public int Id { get; init; }
6+
7+
public required string Name { get; init; }
8+
}

src/ArwynFr.IntegrationTesting.Tests.Target/Program.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
var builder = WebApplication.CreateBuilder(args);
44
builder.Services.AddScoped<IDummyService, DummyService>();
55
builder.Services.AddSqlite<DummyDbContext>("invalid");
6+
builder.Services.AddControllers();
67
var app = builder.Build();
78
app.MapGet("/", () => "Hello World!");
89
app.MapGet("/error", () => { throw new DummyException(); });
910
app.MapGet("/service", (IDummyService service) => service.GetHashCode());
11+
app.MapControllers();
1012
app.Run();
1113

1214
public partial class Program;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using ArwynFr.IntegrationTesting.Tests.Target;
2+
3+
using Microsoft.EntityFrameworkCore;
4+
using Microsoft.Extensions.DependencyInjection;
5+
6+
using Xunit;
7+
using Xunit.Abstractions;
8+
9+
namespace ArwynFr.IntegrationTesting.Tests;
10+
11+
public partial class DriverTest(ITestOutputHelper output) : IntegrationTestBase<Program, DummyDbContext>(output)
12+
{
13+
[Theory, InlineData("random")]
14+
public async Task ExecuteAsync(string name)
15+
{
16+
await Services.GetRequiredService<DummyDriver>()
17+
.Given(x => x.ThereIsAnEntity(name))
18+
.When(x => x.FindEntityByName(name))
19+
.Then(x => x.ResultShouldBe(name))
20+
.ExecuteAsync();
21+
}
22+
23+
protected override void ConfigureAppServices(IServiceCollection services)
24+
{
25+
base.ConfigureAppServices(services);
26+
services.AddSingleton(_ => new DummyDriver(Database, Client));
27+
}
28+
29+
protected override void ConfigureDbContext(DbContextOptionsBuilder builder)
30+
=> builder.UseSqlite($"Data Source={Guid.NewGuid()}.sqlite");
31+
32+
protected override IDatabaseTestStrategy<DummyDbContext> DatabaseTestStrategy
33+
=> IDatabaseTestStrategy<DummyDbContext>.DatabasePerTest;
34+
}

0 commit comments

Comments
 (0)