Skip to content

oskardudycz/Ogooreck

Repository files navigation

Github Actions blog blog Nuget Package Nuget LinkedIn

🥒 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:

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:

👉 Github Sponsors

⭐ 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.