Awesome
🥒 Ogooreck
Ogooreck is a Sneaky Test library. It helps to write readable and self-documenting tests. It's both C# and F# friendly!
Main assumptions:
- write tests seamlessly,
- make them readable,
- cut needed boilerplate by the set of helpful extensions and wrappers,
- don't create a full-blown BDD framework,
- no Domain-Specific Language,
- don't replace testing frameworks (works with all, so XUnit, NUnit, MSTests, etc.),
- testing frameworks and assert library agnostic,
- keep things simple, but allow compositions and extension.
Current available for API testing.
Current available for testing:
Check also my articles:
- Ogooreck introduction,
- Testing business logic in Event Sourcing, and beyond!,
- Writing and testing business logic in F#.
Support
Feel free to create an issue if you have any questions or request for more explanation or samples. I also take Pull Requests!
💖 If this tool helped you - I'd be more than happy if you join the group of my official supporters at:
⭐ Star on GitHub or sharing with your friends will also help!
Business Logic Testing
Ogooreck provides a set of helpers to set up business logic tests. It's recommended to add such using to your tests:
using Ogooreck.BusinessLogic;
Read more in the Testing business logic in Event Sourcing, and beyond! article.
Decider and Command Handling tests
You can use DeciderSpecification
to run decider and command handling tests. See the example:
C#
using FluentAssertions;
using Ogooreck.BusinessLogic;
namespace Ogooreck.Sample.BusinessLogic.Tests.Deciders;
using static BankAccountEventsBuilder;
public class BankAccountTests
{
private readonly Random random = new();
private static readonly DateTimeOffset now = DateTimeOffset.UtcNow;
private readonly DeciderSpecification<BankAccount> Spec = Specification.For<BankAccount>(
(command, bankAccount) => BankAccountDecider.Handle(() => now, command, bankAccount),
BankAccount.Evolve
);
[Fact]
public void GivenNonExistingBankAccount_WhenOpenWithValidParams_ThenSucceeds()
{
var bankAccountId = Guid.NewGuid();
var accountNumber = Guid.NewGuid().ToString();
var clientId = Guid.NewGuid();
var currencyISOCode = "USD";
Spec.Given()
.When(new OpenBankAccount(bankAccountId, accountNumber, clientId, currencyISOCode))
.Then(new BankAccountOpened(bankAccountId, accountNumber, clientId, currencyISOCode, now, 1));
}
[Fact]
public void GivenOpenBankAccount_WhenRecordDepositWithValidParams_ThenSucceeds()
{
var bankAccountId = Guid.NewGuid();
var amount = (decimal)random.NextDouble();
var cashierId = Guid.NewGuid();
Spec.Given(BankAccountOpened(bankAccountId, now, 1))
.When(new RecordDeposit(amount, cashierId))
.Then(new DepositRecorded(bankAccountId, amount, cashierId, now, 2));
}
[Fact]
public void GivenClosedBankAccount_WhenRecordDepositWithValidParams_ThenFailsWithInvalidOperationException()
{
var bankAccountId = Guid.NewGuid();
var amount = (decimal)random.NextDouble();
var cashierId = Guid.NewGuid();
Spec.Given(
BankAccountOpened(bankAccountId, now, 1),
BankAccountClosed(bankAccountId, now, 2)
)
.When(new RecordDeposit(amount, cashierId))
.ThenThrows<InvalidOperationException>(exception => exception.Message.Should().Be("Account is closed!"));
}
}
public static class BankAccountEventsBuilder
{
public static BankAccountOpened BankAccountOpened(Guid bankAccountId, DateTimeOffset now, long version)
{
var accountNumber = Guid.NewGuid().ToString();
var clientId = Guid.NewGuid();
var currencyISOCode = "USD";
return new BankAccountOpened(bankAccountId, accountNumber, clientId, currencyISOCode, now, version);
}
public static BankAccountClosed BankAccountClosed(Guid bankAccountId, DateTimeOffset now, long version)
{
var reason = Guid.NewGuid().ToString();
return new BankAccountClosed(bankAccountId, reason, now, version);
}
}
See full sample in tests.
F#
module BankAccountTests
open System
open Deciders.BankAccount
open Deciders.BankAccountPrimitives
open Deciders.BankAccountDecider
open Ogooreck.BusinessLogic
open FsCheck.Xunit
let random = Random()
let spec =
Specification.For(decide, evolve, Initial)
let BankAccountOpenedWith bankAccountId now version =
let accountNumber =
AccountNumber.parse (Guid.NewGuid().ToString())
let clientId = ClientId.newId ()
let currencyISOCode =
CurrencyIsoCode.parse "USD"
BankAccountOpened
{ BankAccountId = bankAccountId
AccountNumber = accountNumber
ClientId = clientId
CurrencyIsoCode = currencyISOCode
CreatedAt = now
Version = version }
let BankAccountClosedWith bankAccountId now version =
BankAccountClosed
{ BankAccountId = bankAccountId
Reason = Guid.NewGuid().ToString()
ClosedAt = now
Version = version }
[<Property>]
let ``GIVEN non existing bank account WHEN open with valid params THEN bank account is opened``
bankAccountId
accountNumber
clientId
currencyISOCode
now
=
let notExistingAccount = Array.empty
spec
.Given(notExistingAccount)
.When(
OpenBankAccount
{ BankAccountId = bankAccountId
AccountNumber = accountNumber
ClientId = clientId
CurrencyIsoCode = currencyISOCode
Now = now }
)
.Then(
BankAccountOpened
{ BankAccountId = bankAccountId
AccountNumber = accountNumber
ClientId = clientId
CurrencyIsoCode = currencyISOCode
CreatedAt = now
Version = 1 }
)
|> ignore
[<Property>]
let ``GIVEN open bank account WHEN record deposit with valid params THEN deposit is recorded``
bankAccountId
amount
cashierId
now
=
spec
.Given(BankAccountOpenedWith bankAccountId now 1)
.When(
RecordDeposit
{ Amount = amount
CashierId = cashierId
Now = now }
)
.Then(
DepositRecorded
{ BankAccountId = bankAccountId
Amount = amount
CashierId = cashierId
RecordedAt = now
Version = 2 }
)
|> ignore
[<Property>]
let ``GIVEN closed bank account WHEN record deposit with valid params THEN fails with invalid operation exception``
bankAccountId
amount
cashierId
now
=
spec
.Given(
BankAccountOpenedWith bankAccountId now 1,
BankAccountClosedWith bankAccountId now 2
)
.When(
RecordDeposit
{ Amount = amount
CashierId = cashierId
Now = now }
)
.ThenThrows<InvalidOperationException>
|> ignore
See full sample in tests.
Event-Sourced command handlers
You can use HandlerSpecification
to run event-sourced command handling tests for pure functions and entities. See the example:
using Ogooreck.BusinessLogic;
namespace Ogooreck.Sample.BusinessLogic.Tests.Functions.EventSourced;
using static IncidentEventsBuilder;
using static IncidentService;
public class IncidentTests
{
private static readonly DateTimeOffset now = DateTimeOffset.UtcNow;
private static readonly Func<Incident, object, Incident> evolve =
(incident, @event) =>
{
return @event switch
{
IncidentLogged logged => Incident.Create(logged),
IncidentCategorised categorised => incident.Apply(categorised),
IncidentPrioritised prioritised => incident.Apply(prioritised),
AgentRespondedToIncident agentResponded => incident.Apply(agentResponded),
CustomerRespondedToIncident customerResponded => incident.Apply(customerResponded),
IncidentResolved resolved => incident.Apply(resolved),
ResolutionAcknowledgedByCustomer acknowledged => incident.Apply(acknowledged),
IncidentClosed closed => incident.Apply(closed),
_ => incident
};
};
private readonly HandlerSpecification<Incident> Spec = Specification.For<Incident>(evolve);
[Fact]
public void GivenNonExistingIncident_WhenOpenWithValidParams_ThenSucceeds()
{
var incidentId = Guid.NewGuid();
var customerId = Guid.NewGuid();
var contact = new Contact(ContactChannel.Email, EmailAddress: "john@doe.com");
var description = Guid.NewGuid().ToString();
var loggedBy = Guid.NewGuid();
Spec.Given()
.When(() => Handle(() => now, new LogIncident(incidentId, customerId, contact, description, loggedBy)))
.Then(new IncidentLogged(incidentId, customerId, contact, description, loggedBy, now));
}
[Fact]
public void GivenOpenIncident_WhenCategoriseWithValidParams_ThenSucceeds()
{
var incidentId = Guid.NewGuid();
var category = IncidentCategory.Database;
var categorisedBy = Guid.NewGuid();
Spec.Given(IncidentLogged(incidentId, now))
.When(incident => Handle(() => now, incident, new CategoriseIncident(incidentId, category, categorisedBy)))
.Then(new IncidentCategorised(incidentId, category, categorisedBy, now));
}
}
public static class IncidentEventsBuilder
{
public static IncidentLogged IncidentLogged(Guid incidentId, DateTimeOffset now)
{
var customerId = Guid.NewGuid();
var contact = new Contact(ContactChannel.Email, EmailAddress: "john@doe.com");
var description = Guid.NewGuid().ToString();
var loggedBy = Guid.NewGuid();
return new IncidentLogged(incidentId, customerId, contact, description, loggedBy, now);
}
}
See full sample in tests.
State-based command handlers
You can use HandlerSpecification
to run state-based command handling tests for pure functions and entities. See the example:
using Ogooreck.BusinessLogic;
namespace Ogooreck.Sample.BusinessLogic.Tests.Functions.StateBased;
using static IncidentEventsBuilder;
using static IncidentService;
public class IncidentTests
{
private static readonly DateTimeOffset now = DateTimeOffset.UtcNow;
private readonly HandlerSpecification<Incident> Spec = Specification.For<Incident>();
[Fact]
public void GivenNonExistingIncident_WhenOpenWithValidParams_ThenSucceeds()
{
var incidentId = Guid.NewGuid();
var customerId = Guid.NewGuid();
var contact = new Contact(ContactChannel.Email, EmailAddress: "john@doe.com");
var description = Guid.NewGuid().ToString();
var loggedBy = Guid.NewGuid();
Spec.Given()
.When(() => Handle(() => now, new LogIncident(incidentId, customerId, contact, description, loggedBy)))
.Then(new Incident(incidentId, customerId, contact, loggedBy, now, description));
}
[Fact]
public void GivenOpenIncident_WhenCategoriseWithValidParams_ThenSucceeds()
{
var incidentId = Guid.NewGuid();
var loggedIncident = LoggedIncident(incidentId, now);
var category = IncidentCategory.Database;
var categorisedBy = Guid.NewGuid();
Spec.Given(loggedIncident)
.When(incident => Handle(() => now, incident, new CategoriseIncident(incidentId, category, categorisedBy)))
.Then(loggedIncident with { Category = category });
}
}
public static class IncidentEventsBuilder
{
public static Incident LoggedIncident(Guid incidentId, DateTimeOffset now)
{
var customerId = Guid.NewGuid();
var contact = new Contact(ContactChannel.Email, EmailAddress: "john@doe.com");
var description = Guid.NewGuid().ToString();
var loggedBy = Guid.NewGuid();
return new Incident(incidentId, customerId, contact, loggedBy, now, description);
}
}
See full sample in tests.
Event-Driven Aggregate tests
You can use HandlerSpecification
to run event-driven aggregat tests. See the example:
using Ogooreck.BusinessLogic;
using Ogooreck.Sample.BusinessLogic.Tests.Aggregates.EventSourced.Core;
using Ogooreck.Sample.BusinessLogic.Tests.Aggregates.EventSourced.Pricing;
using Ogooreck.Sample.BusinessLogic.Tests.Aggregates.EventSourced.Products;
using Ogooreck.Sample.BusinessLogic.Tests.Functions.EventSourced;
namespace Ogooreck.Sample.BusinessLogic.Tests.Aggregates.EventSourced;
using static ShoppingCartEventsBuilder;
using static ProductItemBuilder;
using static AggregateTestExtensions<ShoppingCart>;
public class ShoppingCartTests
{
private readonly Random random = new();
private readonly HandlerSpecification<ShoppingCart> Spec =
Specification.For<ShoppingCart>(Handle, ShoppingCart.Evolve);
private class DummyProductPriceCalculator: IProductPriceCalculator
{
private readonly decimal price;
public DummyProductPriceCalculator(decimal price) => this.price = price;
public IReadOnlyList<PricedProductItem> Calculate(params ProductItem[] productItems) =>
productItems.Select(pi => PricedProductItem.For(pi, price)).ToList();
}
[Fact]
public void GivenNonExistingShoppingCart_WhenOpenWithValidParams_ThenSucceeds()
{
var shoppingCartId = Guid.NewGuid();
var clientId = Guid.NewGuid();
Spec.Given()
.When(() => ShoppingCart.Open(shoppingCartId, clientId))
.Then(new ShoppingCartOpened(shoppingCartId, clientId));
}
[Fact]
public void GivenOpenShoppingCart_WhenAddProductWithValidParams_ThenSucceeds()
{
var shoppingCartId = Guid.NewGuid();
var productItem = ValidProductItem();
var price = random.Next(1, 1000);
var priceCalculator = new DummyProductPriceCalculator(price);
Spec.Given(ShoppingCartOpened(shoppingCartId))
.When(cart => cart.AddProduct(priceCalculator, productItem))
.Then(new ProductAdded(shoppingCartId, PricedProductItem.For(productItem, price)));
}
}
public static class ShoppingCartEventsBuilder
{
public static ShoppingCartOpened ShoppingCartOpened(Guid shoppingCartId)
{
var clientId = Guid.NewGuid();
return new ShoppingCartOpened(shoppingCartId, clientId);
}
}
public static class ProductItemBuilder
{
private static readonly Random Random = new();
public static ProductItem ValidProductItem() =>
ProductItem.From(Guid.NewGuid(), Random.Next(1, 100));
}
public static class AggregateTestExtensions<TAggregate> where TAggregate : Aggregate
{
public static DecideResult<object, TAggregate> Handle(Handler<object, TAggregate> handle, TAggregate aggregate)
{
var result = handle(aggregate);
var updatedAggregate = result.NewState ?? aggregate;
return DecideResult.For(updatedAggregate, updatedAggregate.DequeueUncommittedEvents());
}
}
See full sample in tests.
State-based Aggregate tests
You can use HandlerSpecification
to run event-driven aggregat tests. See the example:
using FluentAssertions;
using Ogooreck.BusinessLogic;
using Ogooreck.Sample.BusinessLogic.Tests.Aggregates.StateBased.Pricing;
using Ogooreck.Sample.BusinessLogic.Tests.Aggregates.StateBased.Products;
namespace Ogooreck.Sample.BusinessLogic.Tests.Aggregates.StateBased;
using static ShoppingCartEventsBuilder;
using static ProductItemBuilder;
public class ShoppingCartTests
{
private readonly Random random = new();
private readonly HandlerSpecification<ShoppingCart> Spec = Specification.For<ShoppingCart>();
private class DummyProductPriceCalculator: IProductPriceCalculator
{
private readonly decimal price;
public DummyProductPriceCalculator(decimal price) => this.price = price;
public IReadOnlyList<PricedProductItem> Calculate(params ProductItem[] productItems) =>
productItems.Select(pi => PricedProductItem.For(pi, price)).ToList();
}
[Fact]
public void GivenNonExistingShoppingCart_WhenOpenWithValidParams_ThenSucceeds()
{
var shoppingCartId = Guid.NewGuid();
var clientId = Guid.NewGuid();
Spec.Given()
.When(() => ShoppingCart.Open(shoppingCartId, clientId))
.Then((state, _) =>
{
state.Id.Should().Be(shoppingCartId);
state.ClientId.Should().Be(clientId);
state.ProductItems.Should().BeEmpty();
state.Status.Should().Be(ShoppingCartStatus.Pending);
state.TotalPrice.Should().Be(0);
});
}
[Fact]
public void GivenOpenShoppingCart_WhenAddProductWithValidParams_ThenSucceeds()
{
var shoppingCartId = Guid.NewGuid();
var productItem = ValidProductItem();
var price = random.Next(1, 1000);
var priceCalculator = new DummyProductPriceCalculator(price);
Spec.Given(OpenedShoppingCart(shoppingCartId))
.When(cart => cart.AddProduct(priceCalculator, productItem))
.Then((state, _) =>
{
state.ProductItems.Should().NotBeEmpty();
state.ProductItems.Single().Should().Be(PricedProductItem.For(productItem, price));
});
}
}
public static class ShoppingCartEventsBuilder
{
public static ShoppingCart OpenedShoppingCart(Guid shoppingCartId)
{
var clientId = Guid.NewGuid();
return ShoppingCart.Open(shoppingCartId, clientId);
}
}
public static class ProductItemBuilder
{
private static readonly Random Random = new();
public static ProductItem ValidProductItem() =>
ProductItem.From(Guid.NewGuid(), Random.Next(1, 100));
}
See full sample in tests.
API Testing
Ogooreck provides a set of helpers to set up HTTP requests, Response assertions. It's recommended to add such usings to your tests:
using Ogooreck.API;
using static Ogooreck.API.ApiSpecification;
Thanks to that, you'll get cleaner access to helper methods.
See more in samples below!
POST
Ogooreck provides a set of helpers to construct the request (e.g. URI
, BODY
) and check the standardised responses.
public Task POST_CreatesNewMeeting() =>
API.Given()
.When(
POST
URI("/api/meetings/),
BODY(new CreateMeeting(Guid.NewGuid(), "Event Sourcing Workshop"))
)
.Then(CREATED);
PUT
You can also specify headers, e.g. IF_MATCH
to perform an optimistic concurrency check.
public Task PUT_ConfirmsShoppingCart() =>
API.Given()
.When(
PUT,
URI($"/api/ShoppingCarts/{API.ShoppingCartId}/confirmation"),
HEADERS(IF_MATCH(1))
)
.Then(OK);
GET
You can also do response body assertions, to, e.g. out of the box check if the response body is equivalent to the expected one:
public Task GET_ReturnsShoppingCartDetails() =>
API.Given()
.When(GET, URI($"/api/ShoppingCarts/{API.ShoppingCartId}"))
.Then(
OK,
RESPONSE_BODY(new ShoppingCartDetails
{
Id = API.ShoppingCartId,
Status = ShoppingCartStatus.Confirmed,
ProductItems = new List<PricedProductItem>(),
ClientId = API.ClientId,
Version = 2,
}));
You can also use GET_UNTIL
helper to check API that has eventual consistency.
You can use various conditions, e.g. RESPONSE_SUCCEEDED
waits until a response has one of the 2xx statuses. That's useful for new resource creation scenarios.
public Task GET_ReturnsShoppingCartDetails() =>
API.Given()
.When(GET, URI($"/api/ShoppingCarts/{API.ShoppingCartId}"))
.Until(RESPONSE_SUCCEEDED)
.Then(
OK,
RESPONSE_BODY(new ShoppingCartDetails
{
Id = API.ShoppingCartId,
Status = ShoppingCartStatus.Confirmed,
ProductItems = new List<PricedProductItem>(),
ClientId = API.ClientId,
Version = 2,
}));
You can also use RESPONSE_ETAG_IS
helper to check if ETag matches your expected version. That's useful for state change verification.
public Task GET_ReturnsShoppingCartDetails() =>
API.Given()
.When(GET, URI($"/api/ShoppingCarts/{API.ShoppingCartId}"))
.Until(RESPONSE_ETAG_IS(2))
.Then(
OK,
RESPONSE_BODY(new ShoppingCartDetails
{
Id = API.ShoppingCartId,
Status = ShoppingCartStatus.Confirmed,
ProductItems = new List<PricedProductItem>(),
ClientId = API.ClientId,
Version = 2,
}));
You can also do more advanced filtering via RESPONSE_BODY_MATCHES
. That's useful for testing filtering scenarios with eventual consistency (e.g. having Elasticsearch
as storage).
You can also do custom checks on the body, providing expression.
public Task GET_ReturnsShoppingCartDetails() =>
API.Given()
.When(
GET,
URI($"{MeetingsSearchApi.MeetingsUrl}?filter={MeetingName}")
)
.UNTIL(
RESPONSE_BODY_MATCHES<IReadOnlyCollection<Meeting>>(
meetings => meetings.Any(m => m.Id == MeetingId))
)
.Then(
RESPONSE_BODY<IReadOnlyCollection<Meeting>>(meetings =>
meetings.Should().Contain(meeting =>
meeting.Id == MeetingId
&& meeting.Name == MeetingName
)
));
DELETE
Of course, the delete keyword is also supported.
public Task DELETE_ShouldRemoveProductFromShoppingCart() =>
API.Given()
.When(
DELETE,
URI($"/api/ShoppingCarts/{API.ShoppingCartId}/products/{API.ProductItem.ProductId}?quantity={RemovedCount}&unitPrice={API.UnitPrice}"),
HEADERS(IF_MATCH(1))
)
.Then(NO_CONTENT);
Using data from results of the previous tests
For instance created id to shape proper URI.
public class CancelShoppingCartTests: IClassFixture<ApiSpecification<Program>>
{
private readonly ApiSpecification<Program> API;
public CancelShoppingCartTests(ApiSpecification<Program> api) => API = api;
public readonly Guid ClientId = Guid.NewGuid();
[Fact]
[Trait("Category", "Acceptance")]
public Task Delete_Should_Return_OK_And_Cancel_Shopping_Cart() =>
API
.Given(
"Opened ShoppingCart",
POST,
URI("/api/ShoppingCarts"),
BODY(new OpenShoppingCartRequest(clientId: Guid.NewGuid()))
)
.When(
"Cancel Shopping Cart",
DELETE,
URI(ctx => $"/api/ShoppingCarts/{ctx.GetCreatedId()}"),
HEADERS(IF_MATCH(0))
)
.Then(OK);
}
Scenarios and advanced composition
Ogooreck supports various ways of composing the API, e.g.
Classic Async/Await
public async Task POST_WithExistingSKU_ReturnsConflictStatus() =>
{
// Given
var request = new RegisterProductRequest("AA2039485", ValidName, ValidDescription);
// first one should succeed
await API.Given()
.When(
POST,
URI("/api/products/"),
BODY(request)
)
.Then(CREATED);
// second one will fail with conflict
await API.Given()
.When(
POST,
URI("/api/products/"),
BODY(request)
)
.Then(CONFLICT);
}
Joining with And
public async Task POST_WithExistingSKU_ReturnsConflictStatus() =>
{
// Given
var request = new RegisterProductRequest("AA2039485", ValidName, ValidDescription);
// first one should succeed
await API.Given()
.When(
POST,
URI("/api/products/"),
BODY(request)
)
.Then(CREATED)
.And()
.When(
POST,
URI("/api/products/"),
BODY(request)
)
.Then(CONFLICT);
}
Chained Api Scenario
public async Task Post_ShouldReturn_CreatedStatus_With_CartId()
{
var createdReservationId = Guid.Empty;
await API.Scenario(
// Create Reservations
API.Given()
.When(
POST,
URI("/api/Reservations/"),
BODY(new CreateTentativeReservationRequest { SeatId = SeatId })
)
.Then(CREATED,
response =>
{
createdReservationId = response.GetCreatedId<Guid>();
return ValueTask.CompletedTask;
}),
// Get reservation details
_ => API.Given()
.When(
GET
URI($"/api/Reservations/{createdReservationId}")
)
.Then(
OK,
RESPONSE_BODY<ReservationDetails>(reservation =>
{
reservation.Id.Should().Be(createdReservationId);
reservation.Status.Should().Be(ReservationStatus.Tentative);
reservation.SeatId.Should().Be(SeatId);
reservation.Number.Should().NotBeEmpty();
reservation.Version.Should().Be(1);
})),
// Get reservations list
_ => API.Given()
.When(GET, URI("/api/Reservations/"))
.Then(
OK,
RESPONSE_BODY<PagedListResponse<ReservationShortInfo>>(reservations =>
{
reservations.Should().NotBeNull();
reservations.Items.Should().NotBeNull();
reservations.Items.Should().HaveCount(1);
reservations.TotalItemCount.Should().Be(1);
reservations.HasNextPage.Should().Be(false);
var reservationInfo = reservations.Items.Single();
reservationInfo.Id.Should().Be(createdReservationId);
reservationInfo.Number.Should().NotBeNull().And.NotBeEmpty();
reservationInfo.Status.Should().Be(ReservationStatus.Tentative);
})),
// Get reservation history
_ => API.Given()
.When(GET, URI($"/api/Reservations/{createdReservationId}/history"))
.Then(
OK,
RESPONSE_BODY<PagedListResponse<ReservationHistory>>(reservations =>
{
reservations.Should().NotBeNull();
reservations.Items.Should().NotBeNull();
reservations.Items.Should().HaveCount(1);
reservations.TotalItemCount.Should().Be(1);
reservations.HasNextPage.Should().Be(false);
var reservationInfo = reservations.Items.Single();
reservationInfo.ReservationId.Should().Be(createdReservationId);
reservationInfo.Description.Should().StartWith("Created tentative reservation with number");
}))
);
}
XUnit setup
Injecting as Class Fixture
By default, it's recommended to inject ApiSpecification<YourProgram>
instance as ClassFixture
to ensure that all dependencies (e.g. HttpClient
) will be appropriately disposed.
public class CreateMeetingTests: IClassFixture<ApiSpecification<Program>>
{
private readonly ApiSpecification<Program> API;
public CreateMeetingTests(ApiSpecification<Program> api) => API = api;
[Fact]
public Task CreateCommand_ShouldPublish_MeetingCreateEvent() =>
API.Given()
.When(
POST,
URI("/api/meetings/),
BODY(new CreateMeeting(Guid.NewGuid(), "Event Sourcing Workshop"))
)
.Then(CREATED);
}
Setting up data with IAsyncLifetime
Sometimes you need to set up test data asynchronously (e.g. open a shopping cart before cancelling it). You might not want to pollute your tests code with test case setup or do more extended preparation. For that XUnit provides IAsyncLifetime
interface. You can create a fixture derived from the APISpecification
to benefit from built-in helpers and use it later in your tests.
public class GetProductDetailsFixture: ApiSpecification<Program>, IAsyncLifetime
{
public ProductDetails ExistingProduct = default!;
public GetProductDetailsFixture(): base(new WarehouseTestWebApplicationFactory()) { }
public async Task InitializeAsync()
{
var registerProduct = new RegisterProductRequest("IN11111", "ValidName", "ValidDescription");
var productId = await Given()
.When(POST, URI("/api/products"), BODY(registerProduct))
.Then(CREATED)
.GetCreatedId<Guid>();
var (sku, name, description) = registerProduct;
ExistingProduct = new ProductDetails(productId, sku!, name!, description);
}
public Task DisposeAsync() => Task.CompletedTask;
}
public class GetProductDetailsTests: IClassFixture<GetProductDetailsFixture>
{
private readonly GetProductDetailsFixture API;
public GetProductDetailsTests(GetProductDetailsFixture api) => API = api;
[Fact]
public Task ValidRequest_With_NoParams_ShouldReturn_200() =>
API.Given()
.When(GET, URI($"/api/products/{API.ExistingProduct.Id}"))
.Then(OK, RESPONSE_BODY(API.ExistingProduct));
[Theory]
[InlineData(12)]
[InlineData("not-a-guid")]
public Task InvalidGuidId_ShouldReturn_404(object invalidId) =>
API.Given()
.When(GET, URI($"/api/products/{invalidId}"))
.Then(NOT_FOUND);
[Fact]
public Task NotExistingId_ShouldReturn_404() =>
API.Given()
.When(GET, URI($"/api/products/{Guid.NewGuid()}"))
.Then(NOT_FOUND);
}
Credits
Special thanks go to:
- Simon Cropp for MarkdownSnippets that I'm using for plugging snippets to markdown,
- Adam Ralph for BullsEye, which I'm using to make the build process seamless,
- Babu Annamalai that did a similar build setup in Marten which I inspired a lot,
- Dennis Doomen for Fluent Assertions, which I'm using for internal assertions, especially checking the response body.