Clean architecture, Dapper, MediatR, and buzzword bingo (part 3)

.NET

241 views

Clean architecture, Dapper, MediatR, and buzzword bingo (part 3) blog meme

Two layers down, two to go. While we've made some great progress in our last post, I wanted to carve out at least one section in our series discussing testing our application. So far, we've built our domain and persistence layers, but we have yet to actually implement any transactional processes that require the higher up layers that will run the code we've written so far to confirm its correctness. Rather than wait until we've built out our API layer to begin testing our implementation of the data layer (that would be more integration testing, one could argue), a better solution would be to take some time to write some simple and quick unit tests around our persistence layer. With our data layer fully unit tested, we won't have to wait to have an API to interact with via Postman, or some other application testing tool, to ensure he code we have so far is giving us the result sets we expect. With our code unit tested in this fashion, we can use said tests as contracts for our expectation of each operation within our repositories, and grant ourself the ability to safely refactor without fear of unknowingly breaking the application (at least within the persistence layer).

Feel free to checkout the code in this post here. Before we jump into writing the unit tests, let's discuss the tools, approach, and mindset we'll use for writing our tests in each layer of our application (excluding our domain layer, as there is really not much logic there by design):

Testing our Persistence Layer

Before we jump into writing our unit tests for our Dappery.Data project, we'll setup just a bit of test infrastructure code that will assist us with creating an in-memory SQLite database to use within the scope of each test and setup our dependencies that our repositories will need. Some of you might be asking the question, however, why use an in-memory database to test, and not the actual database our application will be using? Without launching into a diatribe about which method is best for our application, let me start by saying that either approach is viable; we just so happen to be using the in-memory database for ease of testing and project bootstrapping. There are perfectly valid reasons for using both approaches, for example:

So, what's the answer to our self imposed rhetorical question about which method to use? A good ole fashioned, it depends. For our use case, we don't have any external APIs that we rely on and no data dependency that is out of our domain, so we'll roll our own in-memory database that will be seeded, modified, and torn down in between each test to ensure a fresh test fixture. Since we'll be using xUnit, we can leverage the testing library's disposable interfaces, shared contexts, and dependency injection to write our unit test in a clean, simple fashion. Now, since this is not really a detailed how-to article with xUnit, I'll quickly gloss over some of our infrastructure code that will form the basis of each unit test class that we'll write, utilizing the disposable paradigm xUnit encourages us to use, and then we'll jump into each test by repository and action.

For our unit tests, we'll be heavily relying on xUnit's concept of collection fixtures. From the xUnit documentation for collection fixtures:

When to use: when you want to create a single test context and share it among tests in several test classes, and have it cleaned up after all the tests in the test classes have finished.

In essence, an xUnit collection fixture allows us to share objects, which our case is the in-memory database, between unit test classes. While our MediatR request handlers will only have a single Unit of Work dependency, collection fixures really shine when we're testing classes with several dependencies that we might want to spread across multiple class files to keep our test domains of a single responsibility. I like to think of a collection fixture as the unit test bootstrapping file, similar to a Startup.cs file in an ASP.NET Core web project. In our collection fixture, we'll bootstrap our in-memory database with seeded data and supply implementations for our Unit of Work and repository classes. Since talk is cheap, let's go ahead and start setting things up by creating a unit test project for our Dappery.Data project within our tests folder:

~/Dappery/tests$ dotnet new xunit -n Dappery.Data.Tests
~/Dappery/tests$ dotnet sln ../Dappery.sln add tests/Dappery.Data.Tests/Dappery.Data.Tests.csproj

Again, I'm one of those weirdos that prefers the command line, so feel free to add the project via your IDE if you want. Next, we'll reference our Dappery.Data project in our new test project, which just boils down to adding the package reference in our Dappery.Data.Tests.csproj file:


<ItemGroup>
    <ProjectReference Include="..\..\src\Dappery.Core\Dappery.Core.csproj"/>
    <ProjectReference Include="..\..\src\Dappery.Data\Dappery.Data.csproj"/>
</ItemGroup>

Notice we've also referenced our Dappery.Core project, which we'll see later that we'll require this dependency to access our IUnitOfWork and repository interfaces. Let's go ahead and add a DataCollectionFixture.cs class within our tests/Dappery.Data.Tests project that will serve as our central collection fixture for our persistence tests.

DataCollectionFixture.cs

namespace Dappery.Data.Tests
{
    using Xunit;

    [CollectionDefinition("DataCollectionFixture")]
    public class DataCollectionFixture : ICollectionFixture<TestFixture>
    {
    }
}

Nothing special, mostly just boilerplate code that tells xUnit how to define our collection fixture, which we'll implement with a TestFixture.cs file in the same directory:

TestFixture.cs

namespace Dappery.Data.Tests
{
    using System;
    using Core.Data;

    public class TestFixture : IDisposable
    {
        protected TestFixture()
        {
            UnitOfWork = new UnitOfWork(null);
        }

        protected IUnitOfWork UnitOfWork { get; }

        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        private void Dispose(bool disposing)
        {
            if (disposing)
            {
                UnitOfWork.Dispose();
            }
        }
    }
}

Again, nothing too complicated here. We simply define our TestFixture which all unit tests will use as a base, and note that this class inherits from the IDisposable interface - this is where the xUnit magic happens. With this inheritance, our TestFixture class will be disposed of inbetween unit test runs, tearing down our database ( bootstrapped through our UnitOfWork), and ensuring we have a fresh test fixture clean from persisted changes made in previous tests. We define a read-only UnitOfWork property that each of our inheritors will be able to access, and finish off with a simple resource clean up disposable implementation that will be utilized by xUnit when it disposes of our TestFixture between test runs. Notice that we instantiate our UnitOfWork using the implementation defined in our Dappery.Data project, which we setup to accept a nullable string? value that, when null, initializes a seeded in-memory SQLite database for us that we'll assert against during our unit tests.

With our initial infrastructure out of the way, let's go ahead and create a BeerRepositoryTest.cs file and write our first test case:

BeerRepositoryTest.cs

namespace Dappery.Data.Tests
{
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Threading.Tasks;
    using Domain.Entities;
    using Shouldly;
    using Xunit;

    public class BeerRepositoryTest : TestFixture
    {
        [Fact]
        public async Task GetAllBeers_WhenInvokedAndBeersExist_ReturnsValidListOfBeers()
        {
            // Arrange
            using var unitOfWork = UnitOfWork;

            // Act
            var beers = (await unitOfWork.BeerRepository.GetAllBeers()).ToList();
            unitOfWork.Commit();

            // Assert
            beers.ShouldNotBeNull();
            beers.ShouldBeOfType<List<Beer>>();
            beers.ShouldNotBeEmpty();
            beers.All(b => b.Brewery != null).ShouldBeTrue();
            beers.All(b => b.Brewery.Address != null).ShouldBeTrue();
            beers.All(b => b.Brewery.Address.BreweryId == b.Brewery.Id).ShouldBeTrue();
            beers.ShouldContain(b => b.Name == "Hexagenia");
            beers.FirstOrDefault(b => b.Name == "Hexagenia")?.BeerStyle.ShouldBe(BeerStyle.Ipa);
            beers.ShouldContain(b => b.Name == "Widowmaker");
            beers.FirstOrDefault(b => b.Name == "Widowmaker")?.BeerStyle.ShouldBe(BeerStyle.DoubleIpa);
            beers.ShouldContain(b => b.Name == "Hooked");
            beers.FirstOrDefault(b => b.Name == "Hooked")?.BeerStyle.ShouldBe(BeerStyle.Lager);
            beers.ShouldContain(b => b.Name == "Pale Ale");
            beers.FirstOrDefault(b => b.Name == "Pale Ale")?.BeerStyle.ShouldBe(BeerStyle.PaleAle);
            beers.ShouldContain(b => b.Name == "Hazy Little Thing");
            beers.FirstOrDefault(b => b.Name == "Hazy Little Thing")?.BeerStyle.ShouldBe(BeerStyle.NewEnglandIpa);
        }
    }
}

Alright, let's breakdown this test:

If we run this unit test, using either the Visual Studio/Rider test runner, or running dotnet test, we'll see that this test passes. If we step through this code via a debug session, we can see exactly what is returned within our repository, each query executing and what its result yields, etc. I'll leave that as an exercise for the reader, but always worth while to validate that our unit tests are truly yielding the results we expect. Let's add an empty test for GetAllBeers() and a couple of tests for our GetBeerById() repository methods:

// ...previous tests

[Fact]
public async Task GetAllBeers_WhenNoBeersExist_ReturnsEmptyListOfBeers()
{
    // Arrange, remove all the beers from our database
    using var unitOfWork = UnitOfWork;
    await unitOfWork.BeerRepository.DeleteBeer(1);
    await unitOfWork.BeerRepository.DeleteBeer(2);
    await unitOfWork.BeerRepository.DeleteBeer(3);
    await unitOfWork.BeerRepository.DeleteBeer(4);
    await unitOfWork.BeerRepository.DeleteBeer(5);

    // Act
    var beers = (await unitOfWork.BeerRepository.GetAllBeers()).ToList();
    unitOfWork.Commit();

    // Assert
    beers.ShouldNotBeNull();
    beers.ShouldBeOfType<List<Beer>>();
    beers.ShouldBeEmpty();
}

[Fact]
public async Task GetBeerById_WhenInvokedAndBeerExists_ReturnsValidBeer()
{
    // Arrange
    using var unitOfWork = UnitOfWork;

    // Act
    var beer = await unitOfWork.BeerRepository.GetBeerById(1);
    unitOfWork.Commit();

    // Assert, validate a few properties
    beer.ShouldNotBeNull();
    beer.ShouldBeOfType<Beer>();
    beer.Name.ShouldBe("Hexagenia");
    beer.BeerStyle.ShouldBe(BeerStyle.Ipa);
    beer.Brewery.ShouldNotBeNull();
    beer.Brewery.Name.ShouldBe("Fall River Brewery");
    beer.Brewery.Address.ShouldNotBeNull();
    beer.Brewery.Address.City.ShouldBe("Redding");
}

[Fact]
public async Task GetBeerById_WhenInvokedAndBeerDoesNotExist_ReturnsNull()
{
    // Arrange
    using var unitOfWork = UnitOfWork;

    // Act
    var beer = await unitOfWork.BeerRepository.GetBeerById(10);
    unitOfWork.Commit();

    // Assert, validate a few properties
    beer.ShouldBeNull();
}

Nothing too complex here, just some simple positive/negative test cases for finding a beer given an ID from the caller. One thing to note is in our GetAllBeers_WhenNoBeersExist_ReturnsEmptyListOfBeers method, we use the unitOfWork to remove all the beers in our test database (probably not the most efficient way, quick and dirty for now), and assert against the empty list that gets returned. While this might not seem too interesting, the beauty is that xUnit, alongside the infrastructure code we setup, will clean up this modified database that we've 'dirtied' the context of, and create an entirely fresh database on the next run, disregarding any transactional changes we made in a previous test. We simply retrieve the beer within our test database and assert the properties Should be what we expect. One of the reasons I prefer using Shouldly is the response messages we receive when a test fails. Let's take a look at an example be changing our assertion of our GetBeerById_WhenInvokedAndBeerExists_ReturnsValidBeer() test method above to expect an incorrect beer name:

[Fact]
public async Task GetBeerById_WhenInvokedAndBeerExists_ReturnsValidBeer()
{
    // Arrange
    using var unitOfWork = UnitOfWork;

    // Act
    var beer = await unitOfWork.BeerRepository.GetBeerById(1);
    unitOfWork.Commit();

    // Assert, validate a few properties
    beer.ShouldNotBeNull();
    beer.ShouldBeOfType<Beer>();
    beer.Name.ShouldBe("A beer that doesn't exist"); // This beer was NOT seeded in our database
    beer.BeerStyle.ShouldBe(BeerStyle.Ipa);
    beer.Brewery.ShouldNotBeNull();
    beer.Brewery.Name.ShouldBe("Fall River Brewery");
    beer.Brewery.Address.ShouldNotBeNull();
    beer.Brewery.Address.City.ShouldBe("Redding");
}

If we run this run this test, we see the following in the console from Shouldly:

Dappery.Data.Tests.BeerRepositoryTest.GetBeerById_WhenInvokedAndBeerExists_ReturnsValidBeer:
    Outcome: Failed
    Error Message:
    Shouldly.ShouldAssertException : beer.Name
    should be
"A beer that doesn't exist"
    but was
"Hexagenia"
    difference
Difference     |  |    |    |    |    |    |    |    |    |    |    |    |    |    |    |    |    |    |    |    |    |
               | \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/
Index          | 0    1    2    3    4    5    6    7    8    9    10   11   12   13   14   15   16   17   18   19   20   ...
Expected Value | A    \s   b    e    e    r    \s   t    h    a    t    \s   d    o    e    s    n    '    t    \s   e    ...
Actual Value   | H    e    x    a    g    e    n    i    a                                                                ...
Expected Code  | 65   32   98   101  101  114  32   116  104  97   116  32   100  111  101  115  110  39   116  32   101  ...
Actual Code    | 72   101  120  97   103  101  110  105  97                                                               ...

Difference     |       |    |    |    |    |    |    |    |    |    |    |    |    |    |    |    |    |    |    |    |    |
               |      \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/  \|/
Index          | ...  4    5    6    7    8    9    10   11   12   13   14   15   16   17   18   19   20   21   22   23   24
Expected Value | ...  e    r    \s   t    h    a    t    \s   d    o    e    s    n    '    t    \s   e    x    i    s    t
Actual Value   | ...  g    e    n    i    a
Expected Code  | ...  101  114  32   116  104  97   116  32   100  111  101  115  110  39   116  32   101  120  105  115  116
Actual Code    | ...  103  101  110  105  97

Of the many reasons I love using Shouldly in all my unit test projects, this is one of my favorites. Shouldly points out exactly what it expected, what it received, and the index differences in the string. Now, this isn't an infomercial on trying to sell you on using Shouldly, but informative failure messages like this can help you quickly identify inconsistencies in your code and fix things at a faster rate than traditional assertion frameworks. Let's finish out our BeerRepositoryTest.cs file by adding the unit tests that will exercise our database commands for our create, update, and delete operations:

// ...previous query tests

[Fact]
public async Task CreateBeer_WhenBeerIsValid_ReturnsNewlyInsertedBeer()
{
    // Arrange
    using var unitOfWork = UnitOfWork;
    var beerToInsert = new Beer
    {
        Name = "Lazy Hazy",
        CreatedAt = DateTime.UtcNow,
        UpdatedAt = DateTime.UtcNow,
        BreweryId = 1,
        BeerStyle = BeerStyle.NewEnglandIpa
    };

    // Act
    var beerId = await unitOfWork.BeerRepository.CreateBeer(beerToInsert);
    var insertedBeer = await unitOfWork.BeerRepository.GetBeerById(beerId);
    unitOfWork.Commit();

    insertedBeer.ShouldNotBeNull();
    insertedBeer.ShouldBeOfType<Beer>();
    insertedBeer.Brewery.ShouldNotBeNull();
    insertedBeer.Brewery.Address.ShouldNotBeNull();
    insertedBeer.Brewery.Beers.ShouldNotBeEmpty();
    insertedBeer.Brewery.Beers.Count.ShouldBe(4);
    insertedBeer.Brewery.Beers.ShouldContain(b => b.Id == insertedBeer.Id);
    insertedBeer.Brewery.Beers.FirstOrDefault(b => b.Id == insertedBeer.Id)?.Name.ShouldBe(beerToInsert.Name);
}

[Fact]
public async Task UpdateBeer_WhenBeerIsValid_ReturnsUpdateBeer()
{
    // Arrange
    using var unitOfWork = UnitOfWork;
    var beerToUpdate = new Beer
    {
        Id = 1,
        Name = "Colossus Imperial Stout",
        UpdatedAt = DateTime.UtcNow,
        BeerStyle = BeerStyle.Stout,
        BreweryId = 1,
    };

    // Act
    await unitOfWork.BeerRepository.UpdateBeer(beerToUpdate);
    var updatedBeer = await unitOfWork.BeerRepository.GetBeerById(beerToUpdate.Id);
    unitOfWork.Commit();

    updatedBeer.ShouldNotBeNull();
    updatedBeer.ShouldBeOfType<Beer>();
    updatedBeer.Brewery.ShouldNotBeNull();
    updatedBeer.Brewery.Address.ShouldNotBeNull();
    updatedBeer.Brewery.Beers.ShouldNotBeEmpty();
    updatedBeer.Brewery.Beers.Count.ShouldBe(3);
    updatedBeer.Brewery.Beers.ShouldContain(b => b.Id == beerToUpdate.Id);
    updatedBeer.Brewery.Beers.ShouldNotContain(b => b.Name == "Hexagenia");
    updatedBeer.Brewery.Beers.FirstOrDefault(b => b.Id == beerToUpdate.Id)?.Name.ShouldBe(beerToUpdate.Name);
}

[Fact]
public async Task DeleteBeer_WhenBeerExists_RemovesBeerFromDatabase()
{
    // Arrange
    using var unitOfWork = UnitOfWork;
    (await unitOfWork.BeerRepository.GetAllBeers())?.Count().ShouldBe(5);

    // Act
    var removeBeerCommand = await unitOfWork.BeerRepository.DeleteBeer(1);
    var breweryOfRemovedBeer = await unitOfWork.BreweryRepository.GetBreweryById(1);
    (await unitOfWork.BeerRepository.GetAllBeers())?.Count().ShouldBe(4);
    unitOfWork.Commit();

    // Assert
    removeBeerCommand.ShouldNotBeNull();
    removeBeerCommand.ShouldBe(1);
    breweryOfRemovedBeer.ShouldNotBeNull();
    breweryOfRemovedBeer.Beers.ShouldNotBeNull();
    breweryOfRemovedBeer.Beers.ShouldNotBeEmpty();
    breweryOfRemovedBeer.Beers.ShouldNotContain(b => b.Name == "Hexagenia");
}

Notice that our tests are simple and clean, naively testing the happy paths for all three commands since, by design, our persistence layer has one job, and one job only: query and command the database. No (checked) exceptions are thrown in this layer, so we don't need any assertion tests to failure cases, and since our validations/mappings will be done in the core business logic layer (as they should be), we exclude tests of that nature as well. With our unit tests in place, we're free to modify our logic within our persistence layer any way we see fit as a simple dotnet test will tell us if we've broken any existing functionality. Our brewery repository tests will be very similar to our beer repository tests, so let's create a BreweryRepositoryTest.cs file within our unit test project with the following tests:

BreweryRepositoryTest.cs

namespace Dappery.Data.Tests
{
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Threading.Tasks;
    using Domain.Entities;
    using Shouldly;
    using Xunit;

    public class BreweryRepositoryTest : TestFixture
    {
        [Fact]
        public async Task GetAllBreweries_WhenInvokedAndBreweriesExist_ReturnsValidListOfBreweries()
        {
            // Arrange
            using var unitOfWork = UnitOfWork;

            // Act
            var breweries = (await unitOfWork.BreweryRepository.GetAllBreweries()).ToList();
            unitOfWork.Commit();

            // Assert
            breweries.ShouldNotBeNull();
            breweries.ShouldNotBeEmpty();
            breweries.Count.ShouldBe(2);
            breweries.All(br => br.Address != null).ShouldBeTrue();
            breweries.All(br => br.Beers != null).ShouldBeTrue();
            breweries.All(br => br.Beers.Any()).ShouldBeTrue();
            breweries.FirstOrDefault(br => br.Name == "Fall River Brewery")?.Beers
                .ShouldContain(b => b.Name == "Hexagenia");
            breweries.FirstOrDefault(br => br.Name == "Fall River Brewery")?.Beers
                .ShouldContain(b => b.Name == "Widowmaker");
            breweries.FirstOrDefault(br => br.Name == "Fall River Brewery")?.Beers
                .ShouldContain(b => b.Name == "Hooked");
            breweries.FirstOrDefault(br => br.Name == "Sierra Nevada Brewing Company")?.Beers
                .ShouldContain(b => b.Name == "Pale Ale");
            breweries.FirstOrDefault(br => br.Name == "Sierra Nevada Brewing Company")?.Beers
                .ShouldContain(b => b.Name == "Hazy Little Thing");
        }

        [Fact]
        public async Task GetAllBreweries_WhenInvokedAndNoBreweriesExist_ReturnsEmptyList()
        {
            // Arrange
            using var unitOfWork = UnitOfWork;
            await unitOfWork.BreweryRepository.DeleteBrewery(1);
            await unitOfWork.BreweryRepository.DeleteBrewery(2);

            // Act
            var breweries = (await unitOfWork.BreweryRepository.GetAllBreweries()).ToList();
            unitOfWork.Commit();

            // Assert
            breweries.ShouldNotBeNull();
            breweries.ShouldBeOfType<List<Brewery>>();
            breweries.ShouldBeEmpty();
        }

        [Fact]
        public async Task GetBreweryById_WhenInvokedAndBreweryExist_ReturnsValidBreweryWithBeersAndAddress()
        {
            // Arrange
            using var unitOfWork = UnitOfWork;

            // Act
            var brewery = await unitOfWork.BreweryRepository.GetBreweryById(1);
            unitOfWork.Commit();

            // Assert
            brewery.ShouldNotBeNull();
            brewery.ShouldBeOfType<Brewery>();
            brewery.Address.ShouldNotBeNull();
            brewery.Beers.ShouldNotBeNull();
            brewery.Beers.ShouldNotBeEmpty();
            brewery.BeerCount.ShouldBe(3);
            brewery.Beers.ShouldContain(b => b.Name == "Hexagenia");
            brewery.Beers.ShouldContain(b => b.Name == "Widowmaker");
            brewery.Beers.ShouldContain(b => b.Name == "Hooked");
        }

        [Fact]
        public async Task GetBreweryById_WhenInvokedAndNoBreweryExist_ReturnsNull()
        {
            // Arrange
            using var unitOfWork = UnitOfWork;

            // Act
            var brewery = await unitOfWork.BreweryRepository.GetBreweryById(11);
            unitOfWork.Commit();

            // Assert
            brewery.ShouldBeNull();
        }

        [Fact]
        public async Task CreateBrewery_WhenBreweryIsValid_ReturnsNewlyInsertedBrewery()
        {
            // Arrange
            using var unitOfWork = UnitOfWork;
            var breweryToInsert = new Brewery
            {
                Name = "Bike Dog Brewing Company",
                Address = new Address
                {
                    StreetAddress = "123 Sacramento St.",
                    City = "Sacramento",
                    State = "CA",
                    ZipCode = "95811",
                    CreatedAt = DateTime.UtcNow,
                    UpdatedAt = DateTime.UtcNow
                },
                CreatedAt = DateTime.UtcNow,
                UpdatedAt = DateTime.UtcNow
            };

            // Act
            var breweryId = await unitOfWork.BreweryRepository.CreateBrewery(breweryToInsert);
            var insertedBrewery = await unitOfWork.BreweryRepository.GetBreweryById(breweryId);
            unitOfWork.Commit();

            // Assert
            insertedBrewery.ShouldNotBeNull();
            insertedBrewery.ShouldBeOfType<Brewery>();
            insertedBrewery.Address.ShouldNotBeNull();
            insertedBrewery.Address.StreetAddress.ShouldBe(breweryToInsert.Address.StreetAddress);
            insertedBrewery.Address.BreweryId.ShouldBe(3);
            insertedBrewery.Beers.ShouldBeEmpty();
        }

        [Fact]
        public async Task UpdateBrewery_WhenBreweryIsValidAndAddressIsNotUpdated_ReturnsUpdatedBrewery()
        {
            // Arrange
            using var unitOfWork = UnitOfWork;
            var breweryToUpdate = new Brewery
            {
                Id = 2,
                Name = "Sierra Nevada Brewing Company Of Brewing",
                Address = new Address
                {
                    StreetAddress = "1075 E 20th St",
                    City = "Chico",
                    State = "CA",
                    ZipCode = "95928",
                    UpdatedAt = DateTime.UtcNow,
                    BreweryId = 2
                },
                UpdatedAt = DateTime.UtcNow
            };

            // Act
            await unitOfWork.BreweryRepository.UpdateBrewery(breweryToUpdate);
            var updatedBrewery = await unitOfWork.BreweryRepository.GetBreweryById(breweryToUpdate.Id);
            unitOfWork.Commit();

            // Assert
            updatedBrewery.ShouldNotBeNull();
            updatedBrewery.ShouldBeOfType<Brewery>();
            updatedBrewery.Address.ShouldNotBeNull();
            updatedBrewery.Address.StreetAddress.ShouldBe(breweryToUpdate.Address.StreetAddress);
            updatedBrewery.Address.BreweryId.ShouldBe(2);
            updatedBrewery.Beers.ShouldNotBeNull();
            updatedBrewery.Beers.ShouldNotBeEmpty();
        }

        [Fact]
        public async Task UpdateBrewery_WhenBreweryIsValidAndAddressIsUpdated_ReturnsUpdatedBrewery()
        {
            // Arrange
            using var unitOfWork = UnitOfWork;
            var breweryToUpdate = new Brewery
            {
                Id = 2,
                Name = "Sierra Nevada Brewing Company Of Brewing",
                Address = new Address
                {
                    Id = 2,
                    StreetAddress = "123 Happy St.",
                    City = "Redding",
                    State = "CA",
                    ZipCode = "96002",
                    UpdatedAt = DateTime.UtcNow,
                    BreweryId = 2
                },
                UpdatedAt = DateTime.UtcNow
            };

            // Act
            await unitOfWork.BreweryRepository.UpdateBrewery(breweryToUpdate, true);
            var updatedBrewery = await unitOfWork.BreweryRepository.GetBreweryById(breweryToUpdate.Id);
            unitOfWork.Commit();

            // Assert
            updatedBrewery.ShouldNotBeNull();
            updatedBrewery.ShouldBeOfType<Brewery>();
            updatedBrewery.Address.ShouldNotBeNull();
            updatedBrewery.Address.StreetAddress.ShouldBe(breweryToUpdate.Address.StreetAddress);
            updatedBrewery.Address.ZipCode.ShouldBe(breweryToUpdate.Address.ZipCode);
            updatedBrewery.Address.City.ShouldBe(breweryToUpdate.Address.City);
            updatedBrewery.Address.BreweryId.ShouldBe(2);
            updatedBrewery.Beers.ShouldNotBeNull();
            updatedBrewery.Beers.ShouldNotBeEmpty();
        }

        [Fact]
        public async Task DeleteBrewery_WhenBreweryExists_RemovesBreweryAndAllAssociatedBeersAndAddress()
        {
            // Arrange
            using var unitOfWork = UnitOfWork;
            (await unitOfWork.BreweryRepository.GetAllBreweries())?.Count().ShouldBe(2);
            (await unitOfWork.BeerRepository.GetAllBeers())?.Count().ShouldBe(5);


            // Act
            var removedBrewery = await unitOfWork.BreweryRepository.DeleteBrewery(1);
            var breweries = (await unitOfWork.BreweryRepository.GetAllBreweries()).ToList();
            (await unitOfWork.BeerRepository.GetAllBeers())?.Count().ShouldBe(2);
            unitOfWork.Commit();

            // Assert
            removedBrewery.ShouldNotBeNull();
            removedBrewery.ShouldBe(1);
            breweries.ShouldNotBeNull();
            breweries.Count.ShouldBe(1);
            breweries.ShouldNotContain(br => br.Name == "Fall River Brewery");
        }
    }
}

Again, pretty similar to the tests within our beer repository file. We see a few scenarios testing our retrieval methods, and one test each for our commands to create, update, and delete breweries that also exercise the connection between breweries and beers. Toss in a few nullable ? operators to make the compiler happy, and we've got a working unit test project. Let's run one final dotnet test to make sure our tests look good so far now that we've covered all of our operations in either repository:

Test run for /path/to/Dappery/tests/Dappery.Data.Tests/bin/Debug/netcoreapp3.0/Dappery.Data.Tests.dll(.NETCoreApp,Version=v3.0)
Microsoft (R) Test Execution Command Line Tool Version 16.3.0
Copyright (c) Microsoft Corporation.  All rights reserved.

Starting test execution, please wait...

A total of 1 test files matched the specified pattern.

Test Run Successful.
Total tests: 15
     Passed: 15
 Total time: 1.8437 Seconds

Music to a developer's ears: 15 tests ran, 15 passed. While it is in fact possible to swap out our in-memory SQLite database for disk-based SQL Server, or Postgres, I prefer to use the mock in-memory versions simply because the database context is refreshed easily for us between test runs and ready to go for any need we may be using it for. As a disclaimer, we will be writing more unit tests for our project, both at the unit and functional level, but I'll allude to each test project within the section during that time. Here's the code we've written so far for our persistence layer. Let's go ahead and leave things here now, and head on to the meat and potatoes of the project: the core business layer!

Not currently listening