Skip to content

pact-foundation/pact-workshop-dotnet-core-v1

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

50 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Example .NET Core Project for Pact Workshop

When writing a lot of small services, testing the interactions between these becomes a major headache. That's the problem Pact is trying to solve.

Integration tests typically are slow and brittle, requiring each component to have its own environment to run the tests in. With a micro-service architecture, this becomes even more of a problem. They also have to be 'all-knowing' and this makes them difficult to keep from being fragile.

After J. B. Rainsberger's talk Integrated Tests Are A Scam people have been thinking how to get the confidence we need to deploy our software to production without having a tiresome integration test suite that does not give us all the coverage we think it does.

PactNet is a .NET implementation of Pact that allows you to define a pact between service consumers and providers. It provides a DSL for service consumers to define the request they will make to a service producer and the response they expect back. This expectation is used in the consumer's specs to provide a mock producer and is also played back in the producer specs to ensure the producer actually does provide the response that the consumer expects.

This allows you to test both sides of an integration point using fast unit tests.

Prerequisites

This workshop while written with .NET Core is not specifically about it so in-depth knowledge of .NET Core is not required if you can write code in any other language you should be fine.

However before taking part in this workshop please make sure you have:

Workshop Steps

Step 1 - Fork the Repo & Explore the Code!

Create a fork of pact-workshop-dotnet-core-v1 and familiarise yourself with its contents. There are two main folders to be aware of:

CompletedSolution

This folder contains a complete sample solution for the workshop so if you get stuck at any point or are unsure what to do next take a look in here and you will see all the completed code for guidance.

Within the folder is a Consumer project in the Consumer/src folder which is a simple .NET Core console application that connects to the Provider project which is in the Provider/src folder and is an ASP.NET Core Web API. Both projects also have a tests/ folder which is where the Pact tests for both projects exist.

YourSolution

This folder follows the same structure as the CompletedSolution/ folder except for the tests/ folders are empty! During this workshop you will be creating the test projects using Pact to test both the Consumer project and the Provider project.

Step 2 - Understanding The Consumer Project

The Consumer is a .NET Core console application which validates date & time strings by making requests to our Provider API. Take a look at the code. You might notice before we can run the project successfully we need the Provider API running locally.

Step 2.1 - Start the Provider API Locally

Using the command line navigate to:

[RepositoryRoot]/YourSolution/Provider/src/

Once in the Provider /src/ directory first do a dotnet restore at the command line to pull down the dependencies required for the project. Once that has completed run dotnet run this will start your the Provider API. Now check that everything is working O.K. by navigating to the URL below in your browser:

http://localhost:9000/api/provider?validDateTime=05/01/2018

If your request is successful you should see in your browser:

{"test":"NO","validDateTime":"05-01-2018 00:00:00"}

If you see the above leave the Provider API running then you are ready to try out the consumer.

NB: Potential Error

If you get a 404 error check that the path [RepositoryRoot]/YourSolution/data exists with a text file in it called somedata.txt in it. We will talk about this file later on.

Step 2.2 - Execute the Consumer

With the Provider API running open another command line instance and navigate to:

[RepositoryRoot]/YourSolution/Consumer/src/

Once in the directory run another dotnet restore to pull down the dependencies for the Consumer project. Once this is completed at the command line type in dotnet run you should see output:

MyPc:src thomas.shipley$ dotnet run
-------------------
Running consumer with args: dateTimeToValidate = 05/01/2018, baseUri = http://localhost:9000
To use with your own parameters:
Usage: dotnet run [DateTime To Validate] [Provider Api Uri]
Usage Example: dotnet run 01/01/2018 http://localhost:9000
-------------------
Validating date...
{"test":"NO","validDateTime":"05-01-2018 00:00:00"}
...Date validation complete. Goodbye.

If you see output similar to above in your command line then the consumer is now running successfully! If you want to now you can experiment with passing in parameters different to the defaults.

Step 3 - Testing the Consumer Project with Pact

Now we have tested the Provider API and Consumer run successfully on your machine we can start to create our Pact tests. Pact files are Consumer Driven that is to say, they work by the Consumer defining in there Pact tests first what they expect from a provider which can be verified by the Provider. So let's follow this convention and create our Consumer tests first.

Step 3.1 - Creating a Test Project for Consumer with XUnit

Pact cannot execute tests on its own it needs a test runner project. For this workshop, we will be using XUnit to create the project navigate to [RepositoryRoot]/YourSolution/Consumer/tests and run:

dotnet new xunit

This will create an empty XUnit project with all the references you need... except Pact.

Run the following to add Pact-Net to your project

dotnet add package PactNet

Finally you will need to add a reference to the Consumer Client project src code. So again at the same command line type and run the command:

dotnet add reference ../src/consumer.csproj

This will allow you to access public code from the Consumer Client project which you will need to do to test the code!

Once this command runs successfully you will have in [RepositoryRoot]/YourSolution/Consumer/tests an empty .NET Core XUnit Project with Pact and we can begin to setup Pact!

NB - Multiple OS Environments

When using Pact tests for your production projects you might want to support multiple OSes. You can with .NET Core specify different packages in your .csproj file based on the operating system but for the purpose of this workshop this is unnecessary. Other language implementations do not always require OS based packages.

Step 3.2 - Configuring the Mock HTTP Pact Server on the Consumer

Pact works by placing a mock HTTP server between the consumer and provider(s) in an application to handle mocked provider interactions on the consumer side and replay this actions on the provider side to verify them. So before we can write Pact tests we need to setup and configure this mock server. This server will be used for all the tests in our Consumer test project.

XUnit shares common resources in a few different ways. For this workshop, we shall create a Class Fixture which will share our mock HTTP server between our consumer tests. Start by creating a file and class called ConsumerPactClassFixture.cs in the root of the Consumer test project ([RepositoryRoot]/YourSolution/Consumer/tests). It should look like:

using System;
using Xunit;

namespace tests
{
    // This class is responsible for setting up a shared
    // mock server for Pact used by all the tests.
    // XUnit can use a Class Fixture for this.
    // See: https://xunit.net/docs/shared-context
    public class ConsumerPactClassFixture
    {
    }
}

Step 3.2.1 - Setup using PactBuilder

The PactBuilder is the class used to build out the configuration we need for Pact which defines among other things where to find our mock HTTP server.

First, at the top of your class add some properties which will be used to store your instance of PactBuilder and store Mock HTTP Server properties:

using System;
using Xunit;
using PactNet;
using PactNet.Mocks.MockHttpService;

namespace tests
{
    // This class is responsible for setting up a shared
    // mock server for Pact used by all the tests.
    // XUnit can use a Class Fixture for this.
    // See: https://xunit.net/docs/shared-context
    public class ConsumerPactClassFixture
    {
        public IPactBuilder PactBuilder { get; private set; }
        public IMockProviderService MockProviderService { get; private set; }

        public int MockServerPort { get { return 9222; } }
        public string MockProviderServiceBaseUri { get { return String.Format("http://localhost:{0}", MockServerPort); } }
    }
}

Above we have setup some properties which ultimately say our Mock HTTP Server will be hosted at http://localhost:9222. With that in place the next step is to add a constructor to start the other properties starting with PactBuilder:

using System;
using Xunit;
using PactNet;
using PactNet.Mocks.MockHttpService;

namespace tests
{
    // This class is responsible for setting up a shared
    // mock server for Pact used by all the tests.
    // XUnit can use a Class Fixture for this.
    // See: https://xunit.net/docs/shared-context
    public class ConsumerPactClassFixture
    {
        public IPactBuilder PactBuilder { get; private set; }
        public IMockProviderService MockProviderService { get; private set; }

        public int MockServerPort { get { return 9222; } }
        public string MockProviderServiceBaseUri { get { return String.Format("http://localhost:{0}", MockServerPort); } }

        public ConsumerPactClassFixture()
        {
            // Using Spec version 2.0.0 more details at https://github.com/pact-foundation/pact-specification
            var pactConfig = new PactConfig
            {
                SpecificationVersion = "2.0.0",
                PactDir = @"..\..\..\..\..\pacts",
                LogDir = @".\pact_logs"
            };

            PactBuilder = new PactBuilder(pactConfig);

            PactBuilder.ServiceConsumer("Consumer")
                       .HasPactWith("Provider");
        }
    }
}

The constructor is doing a couple of things right now:

  • It creates a PactConfig object which allows us to specify:
    • The Pact files will be generated and overwritten too ([RepositoryRoot]/pacts).
    • The Pact Log files will be written to the executing directory.
    • The project will follow Pact Specification 2.0.0
  • Define the name of our Consumer project (Consumer) which will be used in other Pact Test projects.
    • Define the relationships our Consumer project has with others. In this case, just one called "Provider" this name will map to the same name used in the Provider Project Pact tests.

The final thing it needs to do is create an instance of our Mock HTTP service using the now created configuration:

using System;
using Xunit;
using PactNet;
using PactNet.Mocks.MockHttpService;

namespace tests
{
    // This class is responsible for setting up a shared
    // mock server for Pact used by all the tests.
    // XUnit can use a Class Fixture for this.
    // See: https://xunit.net/docs/shared-context
    public class ConsumerPactClassFixture
    {
        public IPactBuilder PactBuilder { get; private set; }
        public IMockProviderService MockProviderService { get; private set; }

        public int MockServerPort { get { return 9222; } }
        public string MockProviderServiceBaseUri { get { return String.Format("http://localhost:{0}", MockServerPort); } }

        public ConsumerPactClassFixture()
        {
            // Using Spec version 2.0.0 more details at https://github.com/pact-foundation/pact-specification
            var pactConfig = new PactConfig
            {
                SpecificationVersion = "2.0.0",
                PactDir = @"..\..\..\..\..\pacts",
                LogDir = @".\pact_logs"
            };

            PactBuilder = new PactBuilder(pactConfig);

            PactBuilder.ServiceConsumer("Consumer")
                       .HasPactWith("Provider");

            MockProviderService = PactBuilder.MockService(MockServerPort);
        }
    }
}

By adding the line MockProviderService = PactBuilder.MockService(MockServerPort); to the constructor we have created our Mock HTTP Server with our specific configuration. We are nearly ready to start mocking out Provider interactions but (in my best Columbo voice) there is just one more thing.

Step 3.2.2 Tearing Down the Pact Mock HTTP Server & Generating the Pact File

If the tests were to use the Class Fixture above as is right now the Mock Server might be left running once the tests have finished and worse no Pact file would be created - so we wouldn't be able to verify our mocks with the Provider API!

It is always a good idea in your tests to teardown any resources used in them at end of the test run. However XUnit doesn't implement teardown methods so instead we can implement the IDisposable interface to handle the clean up of the Mock HTTP Server which will at the same time generate our Pact file. To do this update your ConsumerPactClassFixture class to conform to IDisposable and clean up the server using PactBuilder.Build():

using System;
using Xunit;
using PactNet;
using PactNet.Mocks.MockHttpService;

namespace tests
{
    // This class is responsible for setting up a shared
    // mock server for Pact used by all the tests.
    // XUnit can use a Class Fixture for this.
    // See: https://xunit.net/docs/shared-context
    public class ConsumerPactClassFixture : IDisposable
    {
        public IPactBuilder PactBuilder { get; private set; }
        public IMockProviderService MockProviderService { get; private set; }

        public int MockServerPort { get { return 9222; } }
        public string MockProviderServiceBaseUri { get { return String.Format("http://localhost:{0}", MockServerPort); } }

        public ConsumerPactClassFixture()
        {
            // Using Spec version 2.0.0 more details at https://github.com/pact-foundation/pact-specification
            var pactConfig = new PactConfig
            {
                SpecificationVersion = "2.0.0",
                PactDir = @"..\..\..\..\..\pacts",
                LogDir = @".\pact_logs"
            };

            PactBuilder = new PactBuilder(pactConfig);

            PactBuilder.ServiceConsumer("Consumer")
                       .HasPactWith("Provider");

            MockProviderService = PactBuilder.MockService(MockServerPort);
        }

        #region IDisposable Support
        private bool disposedValue = false; // To detect redundant calls

        protected virtual void Dispose(bool disposing)
        {
            if (!disposedValue)
            {
                if (disposing)
                {
                    // This will save the pact file once finished.
                    PactBuilder.Build();
                }

                disposedValue = true;
            }
        }

        // This code added to correctly implement the disposable pattern.
        public void Dispose()
        {
            // Do not change this code. Put cleanup code in Dispose(bool disposing) above.
            Dispose(true);
        }
        #endregion
    }
}

The PactBuilder.Build() method will teardown the Mock HTTP Server it uses for tests and generates the Pact File used for verifying mocks with providers. It will always overwrite the Pact file with the results of the latest test run.

Step 3.3 - Creating Your First Pact Test for the Consumer Client

With the class fixture created to manage the Mock HTTP Server update the test class added by the dotnet new xunit command to be named ConsumerPactTests and update the file name to match. With that done update the class to conform to the IClassFixture interface and create an instance of your class fixture in the constructor.

using System;
using Xunit;
using PactNet.Mocks.MockHttpService;
using PactNet.Mocks.MockHttpService.Models;
using Consumer;
using System.Collections.Generic;

namespace tests
{
    public class ConsumerPactTests : IClassFixture<ConsumerPactClassFixture>
    {
        private IMockProviderService _mockProviderService;
        private string _mockProviderServiceBaseUri;

        public ConsumerPactTests(ConsumerPactClassFixture fixture)
        {
            _mockProviderService = fixture.MockProviderService;
            _mockProviderService.ClearInteractions(); //NOTE: Clears any previously registered interactions before the test is run
            _mockProviderServiceBaseUri = fixture.MockProviderServiceBaseUri;
        }
    }
}

With an instance of our Mock HTTP Server in our test class, we can add the first test. All the Pact tests added during this workshop will follow the same three steps:

  1. Mock out an interaction with the Provider API.
  2. Interact with the mocked out interaction using our Consumer code.
  3. Assert the result is what we expected.

For the first test, we shall check that if we pass an invalid date string to our Consumer that the Provider API will return a 400 response and a message explaining why the request is invalid.

Step 3.3.1 - Mocking an Interaction with the Provider

Create a test in ConsumerPactTests called ItHandlesInvalidDateParam() and using the code below mock out our HTTP request to the Provider API which will return a 400:

[Fact]
public void ItHandlesInvalidDateParam()
{
    // Arange
    var invalidRequestMessage = "validDateTime is not a date or time";
    _mockProviderService.Given("There is data")
                        .UponReceiving("A invalid GET request for Date Validation with invalid date parameter")
                        .With(new ProviderServiceRequest 
                        {
                            Method = HttpVerb.Get,
                            Path = "/api/provider",
                            Query = "validDateTime=lolz"
                        })
                        .WillRespondWith(new ProviderServiceResponse {
                            Status = 400,
                            Headers = new Dictionary<string, object>
                            {
                                { "Content-Type", "application/json; charset=utf-8" }
                            },
                            Body = new 
                            {
                                message = invalidRequestMessage
                            }
                        });
}

The code above uses the _mockProviderService to setup our mocked response using Pact. Breaking it down by the different method calls:

  • Given("")

This workshop will talk more about the Given method when writing the Provider API Pact test but for now, it is important to know that the Given method manages the state that your test requires to be in place before running. In our example, we require the Provider API to have some data. The Provider API Pact test will parse these given statements and map them to methods which will execute code to setup the required state(s).

  • UponReceiving("")

When this method executes it will add a description of what the mocked HTTP request represents to the Pact file. It is important to be accurate here as this message is what will be shown when a test fails to help a developer understand what went wrong.

  • With(ProviderServiceRequest)

Here is where the configuration for your mocked HTTP request is added. In our example we have added what Method the request is (Get) the Path the request is made to (api/provider/) and the query parameters which in this test is our invalid date time string (validDateTime=lolz).

  • WillRespondWith(ProviderServiceResponse)

Finally, in this method, we define what we expect back from the Provider API for our mocked request. In our case a 400 HTTP Code and a message in the body explaining what the failure was.

All the methods above on running the test will generate a Pact file which will be used by the Provider, API to make the same requests against the actual API to ensure the responses match the expectations of the Consumer.

Step 3.3.2 - Completing Your First Consumer Test

With the mocked response setup the rest of the test can be treated like any other test you would write; perform an action and assert the result:

[Fact]
public void ItHandlesInvalidDateParam()
{
    // Arange
    var invalidRequestMessage = "validDateTime is not a date or time";
    _mockProviderService.Given("There is data")
                        .UponReceiving("A invalid GET request for Date Validation with invalid date parameter")
                        .With(new ProviderServiceRequest 
                        {
                            Method = HttpVerb.Get,
                            Path = "/api/provider",
                            Query = "validDateTime=lolz"
                        })
                        .WillRespondWith(new ProviderServiceResponse {
                            Status = 400,
                            Headers = new Dictionary<string, object>
                            {
                                { "Content-Type", "application/json; charset=utf-8" }
                            },
                            Body = new 
                            {
                                message = invalidRequestMessage
                            }
                        });

    // Act
    var result = ConsumerApiClient.ValidateDateTimeUsingProviderApi("lolz", _mockProviderServiceBaseUri).GetAwaiter().GetResult();
    var resultBodyText = result.Content.ReadAsStringAsync().GetAwaiter().GetResult();

    // Assert
    Assert.Contains(invalidRequestMessage, resultBodyText);
}

With the updated test above it will make a request using our Consumer client and get the mocked interaction back which we assert on to confirm the error message is the one we expect.

Now all that is left to do is run your test. From the [RepositoryRoot]/YourSolution/Consumer/tests/ directory run the dotnet test command at the command line. If successful you should see some output like this:

YourPC:tests thomas.shipley$ dotnet test
Build started, please wait...
Build completed.

Test run for pact-workshop-dotnet-core-v1/YourSolution/Consumer/tests/bin/Debug/netcoreapp2.0/tests.dll(.NETCoreApp,Version=v2.0)
Microsoft (R) Test Execution Command Line Tool Version 15.3.0-preview-20170628-02
Copyright (c) Microsoft Corporation.  All rights reserved.

Starting test execution, please wait...
[xUnit.net 00:00:00.8359010]   Discovering: tests
[xUnit.net 00:00:00.9334030]   Discovered:  tests
[xUnit.net 00:00:00.9399270]   Starting:    tests
[2018-02-15 11:24:24] INFO  WEBrick 1.3.1
[2018-02-15 11:24:24] INFO  ruby 2.2.2 (2015-04-13) [x86_64-darwin13]
[2018-02-15 11:24:24] INFO  WEBrick::HTTPServer#start: pid=57443 port=9222
[xUnit.net 00:00:03.4053200]   Finished:    tests

Total tests: 1. Passed: 1. Failed: 0. Skipped: 0.
Test Run Successful.
Test execution time: 1.4248 Seconds

If you now navigate to [RepositoryRoot]/pacts you will see the pact file your test generated. Take a moment to have a look at what it contains which is a JSON representation of the mocked our requests your test made.

With your Consumer Pact Test passing and your new Pact file we can now create the Provider Pact test which will validate your mocked responses match actual responses from the Provider API.

Step 4 - Testing the Provider Project with Pact

Navigate to the [RepositoryRoot]/YourSolution/Provider/tests directory in your command line and create another new XUnit project by running the command dotnet new xunit. Once again you will also need to add the correct version of the PactNet package using one of the command line commands below:

dotnet add package PactNet

Finally your Provider Pact Test project will need to run its own web server during tests which will be covered in more detail in the next step but for now.

With all the packages added to our Provider API test project, we are ready to move onto the next step; creating an HTTP Server to manage test environment state.

Step 4.1 - Creating a Provider State HTTP Server

The Pact tests for the Provider API will need to do two things:

  1. Manage the state of the Provider API as dictated by the Pact file.
  2. Communicate with the Provider API to verify that the real responses for HTTP requests defined in the Pact file match the mocked ones.

For the first point, we need to create an HTTP API used exclusively by our tests to manage the transitions in the state. The first step is to create a simple web api that is started when your test run starts.

Step 4.1.1 - Creating a Basic Web API to Manage Provider State

First, navigate to your new Provider Tests project ([RepositoryRoot]/YourSolution/Provider/tests/) and create a file and corresponding class called TestStartup.cs. In which we will create a basic Web API using the code below:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using tests.Middleware;
using Microsoft.AspNetCore.Hosting;

namespace tests
{
    public class TestStartup
    {
        public TestStartup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseMiddleware<ProviderStateMiddleware>();
            app.UseMvc();
        }
    }
}

When you created the class above you might have noticed that the compiler has found a compilation error because we haven't created the ProviderStateMiddleware class yet.

Step 4.1.2 - Creating a The Provider State Middleware

When creating a Pact test for a Provider your test needs its own API. The reason for this is so it can manage the state of your API based on what the Pact file needs for each request. This might be actions like ensuring a user is in the database or a user has permission to access a resource.

Above we took the first step to create this API for our tests to access but currently it both doesn't compile and even if we removed the app.UseMiddleware line it wouldn't do anything. We need to create a way for the API to manage the states required by our tests. We will do this by creating a piece of middleware (similar to a controller) that handles requests to the path /provider-states.

Step 4.1.2.1 - Creating the ProviderState Class

First create a new folder at [RepositoryRoot]/YourSolution/Provider/tests/Middleware and create a file and corresponding class called ProviderState.cs and add the following code:

using System.Collections.Generic;

namespace tests.Middleware
{
    /// <summary>
    /// Provider state DTO
    /// </summary>
    /// <param name="State">State description</param>
    /// <param name="Params">State parameters</param>
    public record ProviderState(string State, IDictionary<string, object> Params);
}

This is a simple class which represents the data sent to the /provider-states path. The first property will store the name of Consumer who is requesting the state change. Which in our case is Consumer. The second property stores the state we want the Provider API to be in.

With this class in place, we can create the middleware class.

Step 4.1.2.2 - Creating the ProviderStateMiddleware Class

Again at [RepositoryRoot]/YourSolution/Provider/tests/Middleware create a file and corresponding class called ProviderStateMiddleware.cs. For now add the following code:

using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http;
using System.Text.Json;

namespace tests.Middleware
{
    public class ProviderStateMiddleware
    {

        private static readonly JsonSerializerOptions Options = new()
        {
            PropertyNameCaseInsensitive = true
        };

        private readonly RequestDelegate _next;

        private readonly IDictionary<string, Func<IDictionary<string, object>, Task>> _providerStates;

        /// <summary>
        /// Handle the request
        /// </summary>
        /// <param name="context">Request context</param>
        /// <returns>Awaitable</returns>
        public async Task InvokeAsync(HttpContext context)
        {

            if (!(context.Request.Path.Value?.StartsWith("/provider-states") ?? false))
            {
                await this._next.Invoke(context);
                return;
            }

            context.Response.StatusCode = StatusCodes.Status200OK;


            if (context.Request.Method == HttpMethod.Post.ToString().ToUpper())
            {
                string jsonRequestBody;

                using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8))
                {
                    jsonRequestBody = await reader.ReadToEndAsync();
                }

                try
                {

                    ProviderState providerState = JsonSerializer.Deserialize<ProviderState>(jsonRequestBody, Options);

                    if (!string.IsNullOrEmpty(providerState?.State))
                    {
                        await this._providerStates[providerState.State].Invoke(providerState.Params);
                    }
                }
                catch (Exception e)
                {
                    context.Response.StatusCode = StatusCodes.Status500InternalServerError;
                    await context.Response.WriteAsync("Failed to deserialise JSON provider state body:");
                    await context.Response.WriteAsync(jsonRequestBody);
                    await context.Response.WriteAsync(string.Empty);
                    await context.Response.WriteAsync(e.ToString());
                }
            }
        }
    }
}

The code above gives us a way to handle requests to the /provider-states path and based on the ProviderState.State requested run some associated code but in the code above the _providerStates is empty so let's update the constructor to set up two states and the associated code. The states to be added are:

  1. "There is data"

This state will create a text file called somedata.txt at [RepositoryRoot]/YourSolution/data. This state is currently used by our Consumer Pact test.

  1. "There is no data"

This state will delete the text file somedata.txt at [RepositoryRoot]/YourSolution/data if it exists. This state is not currently used by our Consumer Pact test but could be if some more test cases were added ;).

The code for this looks like:

        public ProviderStateMiddleware(RequestDelegate next)
        {
            _next = next;
            _providerStates = new Dictionary<string, Func<IDictionary<string, object>, Task>>
            {

                ["There is no data"] = RemoveAllData,
                ["There is data"] = AddData
            };
        }

        private async Task RemoveAllData(IDictionary<string, object> parameters)
        {
            string path = Path.Combine(Directory.GetCurrentDirectory(), @"../../../../../data");
            var deletePath = Path.Combine(path, "somedata.txt");

            if (File.Exists(deletePath))
            {
                await Task.Run(() => File.Delete(deletePath));
            }
        }

        private async Task AddData(IDictionary<string, object> parameters)
        {
            string path = Path.Combine(Directory.GetCurrentDirectory(), @"../../../../../data");

            // Create the directory if it doesn't exist
            if (!Directory.Exists(path))
            {
                Directory.CreateDirectory(path);
            }

            var writePath = Path.Combine(path, "somedata.txt");

            if (!File.Exists(writePath))
            {
                using (var fileStream = new FileStream(writePath, FileMode.CreateNew))
                {
                    await fileStream.FlushAsync();
                }
            }
        }

Now we have initialised our _providerStates field with the two states which map to AddData() and RemoveAllData() respectively. Now if our Consumer Pact test contains the step:

    _mockProviderService.Given("There is data");

When setting up a mock request our Provider API Pact test will map this to the AddData() method and create the somedata.txt file if it does not already exist. If the mock defines the Given step as:

    _mockProviderService.Given("There is no data");

Then the RemoveAllData() method will be called and if the somedata.txt file exists it will be deleted.

With this code in place the ProviderStateMiddleware class should be completed and look like:

using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http;
using System.Text.Json;

namespace tests.Middleware
{
    public class ProviderStateMiddleware
    {

        private static readonly JsonSerializerOptions Options = new()
        {
            PropertyNameCaseInsensitive = true
        };

        private readonly RequestDelegate _next;

        private readonly IDictionary<string, Func<IDictionary<string, object>, Task>> _providerStates;

        public ProviderStateMiddleware(RequestDelegate next)
        {
            _next = next;
            _providerStates = new Dictionary<string, Func<IDictionary<string, object>, Task>>
            {

                ["There is no data"] = RemoveAllData,
                ["There is data"] = AddData
            };
        }

        private async Task RemoveAllData(IDictionary<string, object> parameters)
        {
            string path = Path.Combine(Directory.GetCurrentDirectory(), @"../../../../../data");
            var deletePath = Path.Combine(path, "somedata.txt");

            if (File.Exists(deletePath))
            {
                await Task.Run(() => File.Delete(deletePath));
            }
        }

        private async Task AddData(IDictionary<string, object> parameters)
        {
            string path = Path.Combine(Directory.GetCurrentDirectory(), @"../../../../../data");

            // Create the directory if it doesn't exist
            if (!Directory.Exists(path))
            {
                Directory.CreateDirectory(path);
            }

            var writePath = Path.Combine(path, "somedata.txt");

            if (!File.Exists(writePath))
            {
                using (var fileStream = new FileStream(writePath, FileMode.CreateNew))
                {
                    await fileStream.FlushAsync();
                }
            }
        }

        /// <summary>
        /// Handle the request
        /// </summary>
        /// <param name="context">Request context</param>
        /// <returns>Awaitable</returns>
        public async Task InvokeAsync(HttpContext context)
        {

            if (!(context.Request.Path.Value?.StartsWith("/provider-states") ?? false))
            {
                await this._next.Invoke(context);
                return;
            }

            context.Response.StatusCode = StatusCodes.Status200OK;


            if (context.Request.Method == HttpMethod.Post.ToString().ToUpper())
            {
                string jsonRequestBody;

                using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8))
                {
                    jsonRequestBody = await reader.ReadToEndAsync();
                }

                try
                {

                    ProviderState providerState = JsonSerializer.Deserialize<ProviderState>(jsonRequestBody, Options);

                    if (!string.IsNullOrEmpty(providerState?.State))
                    {
                        await this._providerStates[providerState.State].Invoke(providerState.Params);
                    }
                }
                catch (Exception e)
                {
                    context.Response.StatusCode = StatusCodes.Status500InternalServerError;
                    await context.Response.WriteAsync("Failed to deserialise JSON provider state body:");
                    await context.Response.WriteAsync(jsonRequestBody);
                    await context.Response.WriteAsync(string.Empty);
                    await context.Response.WriteAsync(e.ToString());
                }
            }
        }
    }
}

Step 4.1.2 - Starting the Provider States API When the Pact Tests Start

Now we have a Provider States API we need to start it when our Provider Pact tests start. To do this first rename the provided test class when you created the XUnit project to ProviderApiTests.cs and include the code below:

using System;
using System.Collections.Generic;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using PactNet;
using PactNet.Infrastructure.Outputters;
using tests.XUnitHelpers;
using Xunit;
using Xunit.Abstractions;

namespace tests
{
    public class ProviderApiTests : IDisposable
    {
        private string _providerUri { get; }
        private string _pactServiceUri { get; }
        private IWebHost _webHost { get; }
        private ITestOutputHelper _outputHelper { get; }

        public ProviderApiTests(ITestOutputHelper output)
        {
            _outputHelper = output;
            _providerUri = "http://localhost:9000";
            _pactServiceUri = "http://localhost:9001";

            _webHost = WebHost.CreateDefaultBuilder()
                .UseUrls(_pactServiceUri)
                .UseStartup<TestStartup>()
                .Build();

            _webHost.Start();
        }

        [Fact]
        public void EnsureProviderApiHonoursPactWithConsumer()
        {
        }

        #region IDisposable Support
        private bool disposedValue = false; // To detect redundant calls

        protected virtual void Dispose(bool disposing)
        {
            if (!disposedValue)
            {
                if (disposing)
                {
                    _webHost.StopAsync().GetAwaiter().GetResult();
                    _webHost.Dispose();
                }

                disposedValue = true;
            }
        }

        // This code added to correctly implement the disposable pattern.
        public void Dispose()
        {
            // Do not change this code. Put cleanup code in Dispose(bool disposing) above.
            Dispose(true);
        }
        #endregion
    }
}

Reading the code above when the EnsureProviderApiHonoursPactWithConsumer() test is run using the dotnet test command at the command line the constructor will create a new HTTP server with our Provider States API and store it in the _webHost property. Once stored it will start the server so now our test once written can send requests to http://localhost:9001/provider-states to manipulate the state of our Provider API.

There are two other things to note:

  • The Class implements IDisposable to ensure our Provider States API server is stopped when the test is completed.
  • The test requires a running instance of the Provider API server to verify the responses match those expected in the Pact file. This server is not started by the tests.

Step 4.2 - Creating the Provider API Pact Test

With our Provider States API in place and managed by our test when it is run we can complete our test. Update the EnsureProviderApiHonoursPactWithConsumer() test to:

[Fact]
public void EnsureProviderApiHonoursPactWithConsumer()
{
    // Arrange
    var config = new PactVerifierConfig
    {

        // NOTE: We default to using a ConsoleOutput,
        // however xUnit 2 does not capture the console output,
        // so a custom outputter is required.
        Outputters = new List<IOutput>
                        {
                            new XUnitOutput(_outputHelper)
                        },

        // Output verbose verification logs to the test output
        Verbose = true
    };

    //Act / Assert
    IPactVerifier pactVerifier = new PactVerifier("Provider", config);
    pactVerifier.ProviderState($"{_pactServiceUri}/provider-states")
        .WithHttpEndpoint(_providerUri)
        .HonoursPactWith("Consumer")
        .PactUri(@"..\..\..\..\..\pacts\consumer-provider.json")
        .Verify();
}

The Act/Assert part of this test creates a new PactVerifier instance setup with the name of the Provider being verified in our case Provider and the Pact config. We then use a builder pattern, which first uses a call to ProviderState to know where our Provider States API is hosted. Next, the WithHttpEndpoint method takes a URI to where it is hosted. Then the HonoursPactWith() method tells Pact the name of the consumer that generated the Pact which needs to be verified with the Provider API - in our case Consumer. Finally, in our workshop, we point Pact directly to the Pact File (instead of hosting elsewhere) and call Verify to test that the mocked request and responses in the Pact file for our Consumer and Provider match the real responses from the Provider API.

However there is one last step - the test currently doesn't compile as the XUnitOutput class does not exist - so we will create it.

Step 4.2.1 - Creating the XUnitOutput Class

As noted by the comment in ProviderApiTests XUnit doesn't capture the output we want to show in the console to tell us if a test run as passed or failed.

Run the following to add PactNet.Output.Xunit outputter to your project

dotnet add package PactNet.Output.Xunit

In your Provider test, add the outputter to the Pact Verifier Config

var config = new PactVerifierConfig
{

    // NOTE: We default to using a ConsoleOutput,
    // however xUnit 2 does not capture the console output,
    // so a custom outputter is required.
    Outputters = new List<IOutput>
                    {
                        new XunitOutput(_outputHelper)
                    },

    // Output verbose verification logs to the test output
    LogLevel = PactNet.PactLogLevel.Debug
};

Step 4.3 - Running Your Provider API Pact Test

Now we have a test in the Consumer Project which creates our Pact file based on its mock requests to the Provider API and we have a Pact test in the Provider API which consumes this Pact file to verify the mocks match the actual responses we should run the Provider tests!

Step 4.3.1 - Start Your Provider API Locally

In the command line navigate to [RepositoryRoot]/YourSolution/Provider/src and run the command below to start the server:

dotnet run

This should show ouput similar to:

YourPC:src thomas.shipley$ dotnet run
Using launch settings from /Users/thomas.shipley/code/thomas/pact-workshop-dotnet-core-v1/YourSolution/Provider/src/Properties/launchSettings.json...
Hosting environment: Development
Content root path: /Users/thomas.shipley/code/thomas/pact-workshop-dotnet-core-v1/YourSolution/Provider/src
Now listening on: http://localhost:9000
Application started. Press Ctrl+C to shut down.

If you see the output above leave that server running and move on to the next step!

Step 4.3.2 - Run your Provider API Pact Test

First, confirm you have a Pact file at [RepositoryRoot]/YourSolution/pacts called consumer-provider.json.

Next, create another command line window and navigate to [RepositoryRoot]/YourSolution/Provider/tests and to run the tests type in and execute the command below:

dotnet test

Once you run this command and it completes you will hopefully see some output which looks like:

YourPC:tests thomas.shipley$ dotnet test
Build started, please wait...
Build completed.

Test run for /Users/thomas.shipley/code/thomas/pact-workshop-dotnet-core-v1/YourSolution/Provider/tests/bin/Debug/netcoreapp2.0/tests.dll(.NETCoreApp,Version=v2.0)
Microsoft (R) Test Execution Command Line Tool Version 15.3.0-preview-20170628-02
Copyright (c) Microsoft Corporation.  All rights reserved.

Starting test execution, please wait...
[xUnit.net 00:00:03.1234490]   Discovering: tests
[xUnit.net 00:00:03.2294800]   Discovered:  tests
[xUnit.net 00:00:03.2992030]   Starting:    tests
info: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[0]
      User profile is available. Using '/Users/thomas.shipley/.aspnet/DataProtection-Keys' as key repository; keys will not be encrypted at rest.
info: Microsoft.AspNetCore.Hosting.Internal.WebHost[1]
      Request starting HTTP/1.1 POST http://localhost:9001/provider-states application/json 74
info: Microsoft.AspNetCore.Hosting.Internal.WebHost[2]
      Request finished in 24.308ms 200 
info: Microsoft.AspNetCore.Hosting.Internal.WebHost[1]
      Request starting HTTP/1.1 POST http://localhost:9001/provider-states application/json 80
info: Microsoft.AspNetCore.Hosting.Internal.WebHost[2]
      Request finished in 1.849ms 200 
info: Microsoft.AspNetCore.Hosting.Internal.WebHost[1]
      Request starting HTTP/1.1 POST http://localhost:9001/provider-states application/json 74
info: Microsoft.AspNetCore.Hosting.Internal.WebHost[2]
      Request finished in 0.476ms 200 
info: Microsoft.AspNetCore.Hosting.Internal.WebHost[1]
      Request starting HTTP/1.1 POST http://localhost:9001/provider-states application/json 74
info: Microsoft.AspNetCore.Hosting.Internal.WebHost[2]
      Request finished in 0.217ms 200 
[xUnit.net 00:00:06.8562100]   Finished:    tests

Total tests: 1. Passed: 1. Failed: 0. Skipped: 0.
Test Run Successful.
Test execution time: 7.9642 Seconds

Hopefully, you see the above output which means your Pact Provider test was successful! At this point, you now have a working local example of a Pact test suite that tests both the Consumer and Provider sides of an application but a few test cases are missing...

Step 5 - Missing Consumer Pact Test Cases

The Consumer Pact test suite only has one test in it. But there are a few test cases which could also be implemented:

  • It handles an empty date parameter.
  • It handles having no data in the data folder.
  • It parses a date correctly.

For the final step of this workshop take some time to update your Consumer Pact tests to implement one or all of the test cases above. Once done generate a new Pact file by running your Consumer Pact tests and validate your Pact file against the Provider API.

If you are struggling take a look at [RepositoryRoot]/CompletedSolution/Consumer/tests which contains the solutions to each test case. But perhaps give it a go first!

Copyright Notice & Licence

This workshop is a port of the Ruby Project for Pact Workshop with some minor modifications. It is covered under the same Apache License 2.0 as the original Ruby workshop.

Releases

No releases published

Packages

No packages published

Languages