Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/oskardudycz/ogooreck
Sneaky Testing Library in BDD style
https://github.com/oskardudycz/ogooreck
bdd behaviour-driven-development dotnet testing testing-library
Last synced: 4 months ago
JSON representation
Sneaky Testing Library in BDD style
- Host: GitHub
- URL: https://github.com/oskardudycz/ogooreck
- Owner: oskardudycz
- License: mit
- Created: 2022-03-27T19:10:46.000Z (almost 3 years ago)
- Default Branch: main
- Last Pushed: 2024-05-03T06:39:40.000Z (10 months ago)
- Last Synced: 2024-05-03T11:32:31.655Z (10 months ago)
- Topics: bdd, behaviour-driven-development, dotnet, testing, testing-library
- Language: C#
- Homepage:
- Size: 150 KB
- Stars: 124
- Watchers: 5
- Forks: 1
- Open Issues: 9
-
Metadata Files:
- Readme: README.md
- Funding: .github/FUNDING.yml
- License: LICENSE
Awesome Lists containing this project
README
[![Twitter Follow](https://img.shields.io/twitter/follow/oskar_at_net?style=social)](https://twitter.com/oskar_at_net) ![Github Actions](https://github.com/oskardudycz/Ogooreck/actions/workflows/build.dotnet.yml/badge.svg?branch=main) [![blog](https://img.shields.io/badge/blog-event--driven.io-brightgreen)](https://event-driven.io/?utm_source=event_sourcing_net) [![blog](https://img.shields.io/badge/%F0%9F%9A%80-Architecture%20Weekly-important)](https://www.architecture-weekly.com/?utm_source=event_sourcing_net)
[![Nuget Package](https://badgen.net/nuget/v/ogooreck)](https://www.nuget.org/packages/Ogooreck/)
[![Nuget](https://img.shields.io/nuget/dt/ogooreck)](https://www.nuget.org/packages/Ogooreck/)# 🥒 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:
- [Business Logic](#business-logic-testing),
- [API](#api-testing).Check also my articles:
- [Ogooreck introduction](https://event-driven.io/en/ogooreck_sneaky_bdd_testing_framework/),
- [Testing business logic in Event Sourcing, and beyond!](https://event-driven.io/en/testing_event_sourcing/),
- [Writing and testing business logic in F#](https://event-driven.io/en/writing_and_testing_business_logic_in_fsharp/).## Support
Feel free to [create an issue](https://github.com/oskardudycz/Ogooreck/issues/new) 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](https://github.com/sponsors/oskardudycz)
⭐ 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:
```csharp
using Ogooreck.BusinessLogic;
```Read more in the [Testing business logic in Event Sourcing, and beyond!](https://event-driven.io/en/testing_event_sourcing/) article.
### Decider and Command Handling tests
You can use `DeciderSpecification` to run decider and command handling tests. See the example:
**C#**
```csharp
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 Spec = Specification.For(
(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(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](/src/Ogooreck.Sample.BusinessLogic.Tests/Deciders/BankAccountTests.cs).
**F#**
```fsharp
module BankAccountTestsopen System
open Deciders.BankAccount
open Deciders.BankAccountPrimitives
open Deciders.BankAccountDecider
open Ogooreck.BusinessLogic
open FsCheck.Xunitlet 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 }[]
let ``GIVEN non existing bank account WHEN open with valid params THEN bank account is opened``
bankAccountId
accountNumber
clientId
currencyISOCode
now
=
let notExistingAccount = Array.emptyspec
.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[]
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[]
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
|> ignore
```See full sample in [tests](/src/Ogooreck.Sample.BusinessLogic.FSharp.Tests/Deciders/BankAccountTests.fs).
### Event-Sourced command handlers
You can use `HandlerSpecification` to run event-sourced command handling tests for pure functions and entities. See the example:
```csharp
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 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 Spec = Specification.For(evolve);
[Fact]
public void GivenNonExistingIncident_WhenOpenWithValidParams_ThenSucceeds()
{
var incidentId = Guid.NewGuid();
var customerId = Guid.NewGuid();
var contact = new Contact(ContactChannel.Email, EmailAddress: "[email protected]");
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: "[email protected]");
var description = Guid.NewGuid().ToString();
var loggedBy = Guid.NewGuid();return new IncidentLogged(incidentId, customerId, contact, description, loggedBy, now);
}
}
```See full sample in [tests](/src/Ogooreck.Sample.BusinessLogic.Tests/Functions/EventSourced/IncidentTests.cs).
### State-based command handlers
You can use `HandlerSpecification` to run state-based command handling tests for pure functions and entities. See the example:
```csharp
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 Spec = Specification.For();
[Fact]
public void GivenNonExistingIncident_WhenOpenWithValidParams_ThenSucceeds()
{
var incidentId = Guid.NewGuid();
var customerId = Guid.NewGuid();
var contact = new Contact(ContactChannel.Email, EmailAddress: "[email protected]");
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: "[email protected]");
var description = Guid.NewGuid().ToString();
var loggedBy = Guid.NewGuid();return new Incident(incidentId, customerId, contact, loggedBy, now, description);
}
}```
See full sample in [tests](/src/Ogooreck.Sample.BusinessLogic.Tests/Functions/EventSourced/IncidentTests.cs).
### Event-Driven Aggregate tests
You can use `HandlerSpecification` to run event-driven aggregat tests. See the example:
```csharp
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;public class ShoppingCartTests
{
private readonly Random random = new();private readonly HandlerSpecification Spec =
Specification.For(Handle, ShoppingCart.Evolve);private class DummyProductPriceCalculator: IProductPriceCalculator
{
private readonly decimal price;public DummyProductPriceCalculator(decimal price) => this.price = price;
public IReadOnlyList 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 where TAggregate : Aggregate
{
public static DecideResult Handle(Handler handle, TAggregate aggregate)
{
var result = handle(aggregate);
var updatedAggregate = result.NewState ?? aggregate;
return DecideResult.For(updatedAggregate, updatedAggregate.DequeueUncommittedEvents());
}
}
```See full sample in [tests](/src/Ogooreck.Sample.BusinessLogic.Tests/Aggregates/EventSourced/ShoppingCartTests.cs).
### State-based Aggregate tests
You can use `HandlerSpecification` to run event-driven aggregat tests. See the example:
```csharp
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 Spec = Specification.For();
private class DummyProductPriceCalculator: IProductPriceCalculator
{
private readonly decimal price;public DummyProductPriceCalculator(decimal price) => this.price = price;
public IReadOnlyList 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](/src/Ogooreck.Sample.BusinessLogic.Tests/Aggregates/StateBased/ShoppingCartTests.cs).
## 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:
```csharp
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.
```csharp
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.
```csharp
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:
```csharp
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(),
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.
```csharp
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(),
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.
```csharp
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(),
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.
```csharp
public Task GET_ReturnsShoppingCartDetails() =>
API.Given()
.When(
GET,
URI($"{MeetingsSearchApi.MeetingsUrl}?filter={MeetingName}")
)
.UNTIL(
RESPONSE_BODY_MATCHES>(
meetings => meetings.Any(m => m.Id == MeetingId))
)
.Then(
RESPONSE_BODY>(meetings =>
meetings.Should().Contain(meeting =>
meeting.Id == MeetingId
&& meeting.Name == MeetingName
)
));
```### DELETE
Of course, the delete keyword is also supported.
```csharp
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.
```csharp
public class CancelShoppingCartTests: IClassFixture>
{
private readonly ApiSpecification API;
public CancelShoppingCartTests(ApiSpecification 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**
```csharp
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`**
```csharp
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**
```csharp
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();
return ValueTask.CompletedTask;
}),// Get reservation details
_ => API.Given()
.When(
GET
URI($"/api/Reservations/{createdReservationId}")
)
.Then(
OK,
RESPONSE_BODY(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>(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>(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` instance as `ClassFixture` to ensure that all dependencies (e.g. `HttpClient`) will be appropriately disposed.
```csharp
public class CreateMeetingTests: IClassFixture>
{
private readonly ApiSpecification API;public CreateMeetingTests(ApiSpecification 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.
```csharp
public class GetProductDetailsFixture: ApiSpecification, 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();var (sku, name, description) = registerProduct;
ExistingProduct = new ProductDetails(productId, sku!, name!, description);
}public Task DisposeAsync() => Task.CompletedTask;
}public class GetProductDetailsTests: IClassFixture
{
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](https://github.com/SimonCropp/MarkdownSnippets) that I'm using for plugging snippets to markdown,
- Adam Ralph for [BullsEye](https://github.com/adamralph/bullseye), which I'm using to make the build process seamless,
- [Babu Annamalai](https://mysticmind.dev/) that did a similar build setup in [Marten](https://martendb.io/) which I inspired a lot,
- Dennis Doomen for [Fluent Assertions](https://fluentassertions.com/), which I'm using for internal assertions, especially checking the response body.