Click here to Skip to main content
15,881,044 members
Articles / Programming Languages / C#

Fluent Web API Integration Testing

Rate me:
Please Sign up or sign in to vote.
5.00/5 (13 votes)
22 May 2021CPOL16 min read 16.7K   125   17   3
Write readable integration tests that exclusively call Web APIs
For the kind of work that I do, web API integration testing isn't just a simple matter of calling an API and verifying that I get the expected result, rather it's actually a workflow. For example, instead of setting up all the prerequisite data to conduct a single API test, the API itself is enlisted to do help with the data setup. Furthermore, the user's workflow is then tested which requires calling multiple endpoints sequentially.

Contents

Introduction

I was reading Pete O'Hanlon's article "Excelsior! Building Applications Without a Safety Net - Part 1" (he has more parts now, since my article took a while to write) and was inspired to finally sit down and write an article on Fluent web API integrating testing, something I've been wanting to do for a while!

For the kind of work that I do, web API integration testing isn't just a simple matter of calling an API and verifying that I get the expected result, rather it's actually a workflow. For example, instead of setting up all the prerequisite data to conduct a single API test, the API itself is enlisted to do help with the data setup. Furthermore, the user's workflow is then tested which requires calling multiple endpoints sequentially. The benefits are:

  1. It simplifies the test setup process.
  2. It more closely simulates what the user might do or what the front-end application does for the user.
  3. It vets the API for whether it truly supports atomic, if you will, behavior as opposed to, say, a controller that does a dozen different things.
    1. Yes, you may still have endpoints that perform complex operations, but the point is these should be based on more "atomic" methods that simpler API endpoints can call.
  4. If models are used, it tends to enforce an architecture is which the models are maintained in a separate assembly that can be shared between the service implementation and the integration test application.
  5. The concept integrates well with Fluent Assertions, which we'll use here.

Concept

The concept is very simple:

  1. We have an integration test suite (actually implemented using the unit test framework)...
  2. ...that calls methods in our "fluent" library...
  3. ...which calls the desired endpoints...
  4. ...and we can capture the results.

The last part "we can capture the results" is the interesting part, as we want to capture:

  1. The resulting HTTP status code and text.
  2. The returned JSON (yes, I'm assuming everything is going to be JSON).
  3. Deserialize the JSON if there are no errors in the call.
  4. Associate the deserialized object with a label that we can use to reference it later on.

This requires that we implement a wrapper class for the test workflow that manages the information described above. I've never been able to come up with a good name for this, so I'll simply call it the "workflow packet."

Setup

Web API Service

We'll create a new VS 2019 project:

Image 1

and select:

Image 2

and I'm going to call the project "FluentWebApiIntegrationTestDemo."

Visual Studio 2019 creates the basic template for the web API, including a sample Weather Forecast controller:

Image 3

which I'm going to rename and gut, so it looks simply like this:

C#
using System;

using Microsoft.AspNetCore.Mvc;

namespace FluentWebApiIntegrationTestDemo.Controllers
{
  [ApiController]
  [Route("[controller]")]
  public class DemoController : ControllerBase
  {
    [HttpGet]
    public object Get()
    {
      throw new NotImplementedException();
    }
  }
}

and delete the WeatherForecast.cs file.

For testing in the browser, the Debug configuration will open the browser on the controller name:

Image 4

and will launch IIS so I don't have to deal with port silliness:

Image 5

We can then run the project (VS will provision IIS the first time, which is awesome), and we see:

Image 6

Great!

Demo Integration Tests DLL

Next, I'll add an MSTest Test Project (.NET Core) - yes, I'm using the unit test framework to perform integration tests.

Image 7

Creating the integration test project resulted in a nightmare of errors:

Image 8

The only "solution" I found was to put the web API project and the integration test project side-by-side:

Image 9

Whatever Visual Studio is doing with projects created in the same folder as the Web Core API project... well, it's doing too much, as the folder structure should not, in my opinion, have anything to do with how the Web Core API project is built.

I also upgraded the packages:

Image 10

to:

Image 11

So finally that builds.

The initial UnitTest1.cs file that VS creates, I've renamed to "DemoTests" and this stub looks like this:

C#
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace IntegrationTests
{
  [TestClass]
  public class DemoTests
  {
    [TestMethod]
    public void GetTest()
    {
    }
  }
}

Of course, the test doesn't do anything, so we have a successful test! Sorry Pete.

Image 12

The Clifton.IntegrationTestWorkflowEngine DLL

Because these integration tests can actually be leveraged as "live" workflows, I'm going to create a separate .NET Core Class Library project for managing the fluent integration workflow packet:

Image 13

which at the moment contains just a stub file:

C#
namespace Clifton.IntegrationTestWorkflowEngine
{
  public class WorkflowPacket
  {
  }
}

This seems like overkill to create a DLL with just one class, but we may want to add additional functionality later. The point here is that we want a reusable class that does not allow us to add "domain specific" implementations, thus we create a separate DLL to prevent that.

The WorkflowTestMethods DLL

And if the above wasn't enough, yes, we're going to create one more .NET Core Class Library DLL to actually contain the workflow methods. This will allow us to use the workflow methods directly in the API if we so choose to expose workflows to the user. Might as well plan ahead rather than refactor later. This DLL is domain specific - it will contain methods for calling endpoints in our demo API service.

It also has a stub class:

C#
namespace WorkflowTestMethods
{
  public class ApiMethods
  {
  }
}

Our First Fluent Integration Test

After setting up a couple project references, we can write our first fluent integration test:

C#
string baseUrl = "<a href="http://localhost/FluentWebApiIntegrationtestDemo">http://localhost/FluentWebApiIntegrationtestDemo</a>";

new WorkflowPacket(baseUrl)
  .Home("Demo")
  .IShouldSeeOKResponse();

This doesn't compile because we haven't implemented a constructor that takes the base URL nor the supporting methods, but from the syntax with can glean:

  1. The fluent methods are extensions on the WorkflowPacket
  2. Each fluent method returns the WorkflowPacket instance.

Refactoring the WorkflowPacket Class

Refactoring the WorkflowPacket class:

C#
using System.Net;

namespace Clifton.IntegrationTestWorkflowEngine
{
  public class WorkflowPacket
  {
    public HttpStatusCode LastResponse { get; set; }
    public string BaseUrl { get; protected set; }

    public WorkflowPacket(string baseUrl)
    {
      this.BaseUrl = baseUrl;
    }
  }
}

Refactoring the ApiMethods Class

Refactoring the ApiMethods (I also added the FluentAssertions package) class:

C#
using FluentAssertions;

using Clifton.IntegrationTestWorkflowEngine;
using System.Net;

namespace WorkflowTestMethods
{
  public static class ApiMethods
  {
    public static WorkflowPacket Home(this WorkflowPacket wp, string controller)
    {
      return wp; 
    }

    public static WorkflowPacket IShouldSeeOKResponse(this WorkflowPacket wp)
    {
      wp.LastResponse.Should().Be(HttpStatusCode.OK, $"Did not expected {wp.LastContent}");

      return wp;
    }

    public static WorkflowPacket IShouldSeeNoContentResponse(this WorkflowPacket wp)
    {
      wp.LastResponse.Should().Be
      (HttpStatusCode.NoContent, $"Did not expected {wp.LastContent}");

      return wp;
    }

    public static WorkflowPacket IShouldSeeBadRequestResponse(this WorkflowPacket wp)
    {
      wp.LastResponse.Should().Be
      (HttpStatusCode.BadRequest, $"Did not expected {wp.LastContent}");

      return wp;
    }
  }
}

Now, FluentAssertions is a bit lame. One can say "we assert that x should be y because [some reason]", but there's no mechanism to say "we assert that x should be y but it is not for the reason [fail reason]. So that's why the "because" parameter has "Did not expected...". Sigh.

Test Result

We see that the integration test failed (obviously, because we're not calling the endpoint yet):

Image 14

The amazing thing about FluentAssertions is that it tells you exactly what the issue is:

Image 15

Adding the Endpoint Call

Using RestSharp (yet another package), which I'm going to wrap in a RestService class and put in the Clifton.IntegrationTestWorkflowEngine (hah! see, I told you we would add more to this DLL!), we have a simple GET API call method:

C#
using System.Net;

using RestSharp;

namespace Clifton.IntegrationTestWorkflowEngine
{
  public static class RestService
  {
    public static (HttpStatusCode status, string content) Get(string url)
    {
      var response = Execute(url, Method.GET);

      return (response.StatusCode, response.Content);
    }

    private static IRestResponse Execute(string url, Method method)
    {
      var client = new RestClient(url);
      var request = new RestRequest(method);
      var response = client.Execute(request);

      return response;
    }
  }
}

We then refactor the ApiMethods.Home method to make the call:

C#
public static WorkflowPacket Home(this WorkflowPacket wp, string controller)
{
  var resp = RestService.Get($"{wp.BaseUrl}/{controller}");
  wp.LastResponse = resp.status;

  return wp; 
}

Test Result

The test still fails, but now we see why:

Image 16

Refactoring the Endpoint

So the last step is to refactor the endpoint so it doesn't throw a NotImplementedException exception but instead returns OK:

C#
[HttpGet]
public object Get()
{
  return Ok();
}

Test Result

Image 17

And finally the test passes!

Review

What have we accomplished? Given this simple example:

C#
new WorkflowPacket(baseUrl)
  .Home("Demo")
  .IShouldSeeOKResponse();

We have created the basic framework for:

  1. Calling endpoints
  2. Verifying status returns

Let's extend this further now to work with more "real" API endpoints.

Real Endpoint Fluent Integration Tests

The purpose here is to be able to pass in some data to an endpoint (query string or serialization) and obtain the result (deserialization) and test the result. So, for example:

C#
[TestMethod]
public void FactorialTest()
{
  string baseUrl = "http://localhost/FluentWebApiIntegrationtestDemo";

  new WorkflowPacket(baseUrl)
   .Factorial<FactorialResult>("factResult", 6)
   .IShouldSeeOKResponse()
   .ThenIShouldSee<FactorialResult>("factResult", r => r.Result.Should().Be(720));
}

[TestMethod]
public void BadFactorialTest()
{
  string baseUrl = "http://localhost/FluentWebApiIntegrationtestDemo";

  new WorkflowPacket(baseUrl)
    .Factorial<FactorialResult>("factResult", -1)
    .IShouldSeeBadRequestResponse();
}

Note that this implies that the workflow packet now stores the result in the indicated "container", in this case, "factResult."

Also note that I am expecting, in the second test, that an HTTP response of BadRequest will be returned if I try to obtain the factorial of a number less than 1.

I'm going to put the FactorialResult "model" into another DLL that is shared by both the integration test and the API service:

C#
namespace FluentWebApiIntegrationTestDemoModels
{
  public class FactorialResult
  {
    public decimal Result { get; set; }
  }
}

Because these are generic methods, we do not add this DLL to the WorkflowTestMethods project.

Adding the Generic Get REST API Call

Adding the Newtonsoft.Json package, we implement:

C#
public static (T item, HttpStatusCode status, string content) Get<T>(string url) where T : new()
{
  var response = Execute(url, Method.GET);
  T ret = TryDeserialize<T>(response);

  return (ret, response.StatusCode, response.Content);
}

private static T TryDeserialize<T>(IRestResponse response) where T : new()
{
  T ret = new T();
  int code = (int)response.StatusCode;

  if (code >= 200 && code < 300)
  {
    ret = JsonConvert.DeserializeObject<T>(response.Content);
  }

  return ret;
}

Adding the Workflow API Call Method

Here, the endpoint to the Factorial method in the Math controller, is hard-coded, which I think is perfectly fine because the description of the API call method should be specific so that the integration test is readable by its method name not by its parameters.

C#
public static WorkflowPacket Factorial<T>
(this WorkflowPacket wp, string containerName, int n) where T: new()
{
  var resp = RestService.Get<T>($"{wp.BaseUrl}/Math/Factorial?n={n}");
  wp.LastResponse = resp.status;
  wp.Container[containerName] = resp.item;

  return wp;
}

public static WorkflowPacket ThenIShouldSee<T>
(this WorkflowPacket wp, string containerName, Action<T> test) where T : class
{
  T obj = wp.GetObject<T>(containerName);
  test(obj);

  return wp;
}

Notice that I've now added the concept of containers to the WorkflowPacket, such that I can add objects to the container and return the container object, cast to the specified type.

C#
public Dictionary<string, object> Container = new Dictionary<string, object>();
...
public T GetObject<T>(string containerName) where T: class
{
  Container.Should().ContainKey(containerName);
  T ret = Container[containerName] as T;

  return ret;
}

Of course, the test fails because I haven't implemented that Math controller with the Factorial method, so let's do that now as a stub:

C#
[ApiController]
[Route("[controller]")]
public class MathController : ControllerBase
{
  [HttpGet("Factorial")]
  public object Factorial([FromQuery, BindRequired] int n)
  {
    return Ok(new FactorialResult());
  }
}

Again, the test fails, but we see:

Image 18

So let's actually implement the factorial computation:

C#
[ApiController]
[Route("[controller]")]
public class MathController : ControllerBase
{
  [HttpGet("Factorial")]
  public object Factorial([FromQuery, BindRequired] int n)
  {
    object ret;

    if (n <= 0)
    {
      ret = BadRequest("Value must be >= 1");
    }
    else
    {
      decimal factorial = 1;
      n.ForEach(i => factorial = factorial * i, 1);

      ret = Ok(new FactorialResult() { Result = factorial });
    }

    return ret;
  }
}

(Yes, I like my extension methods.) Note how I specifically coded a test to make sure n is greater than 0.

And we see:

Image 19

Review

Given this integration test:

C#
new WorkflowPacket(baseUrl)
   .Factorial<FactorialResult>("factResult", 6)
   .IShouldSeeOKResponse()
   .ThenIShouldSee<FactorialResult>("factResult", r => r.Result.Should().Be(720));

and barring the fact that we unfortunately have to over-specify the generics, we see that we can:

  1. Make an API endpoint call with a query parameter.
  2. Deserialize the result.
  3. Verify the result.

Failure Testing

We also implemented a simple integration test that verifies that the API gracefully handles bad input with a simple workflow:

C#
new WorkflowPacket(baseUrl)
 .Factorial<FactorialResult>("factResult", -1)
 .IShouldSeeBadRequestResponse();

Using Dynamic to Reduce Generic Parameter Specification

If we wanted to use the dynamic feature of C# (though we lose Intellisense), we could, with a slightly different workflow method, write:

C#
.ThenIShouldSee("factResult", r => r.Result.Should().Be(720));

Except that we get an exception from the runtime binder:

Image 20

We could implement a dynamic ThenIShouldSee like this:

C#
public static WorkflowPacket ThenIShouldSee
(this WorkflowPacket wp, string containerName, Func<dynamic, bool> test)
{
  var obj = wp.GetObject(containerName);
  var b = test(obj);
  b.Should().BeTrue();

  return wp;
}

with the test written as:

C#
.ThenIShouldSee("factResult", r => r.Result == 720M);

but then look what happens:

Image 21

What? It turns out that var b, even though we and the complier know b is of type bool, does not work well with FluentAssertions. We actually have to write bool b for FluentAssertions to work!

Going Further - POST and using JSON Body

It's more typical in an integration test to emulate several activities that the user might perform. For this example, we'll create some tests that, if there was a UI, would let the user enter states and counties for each state, and view the counties by state. A simple set of endpoints which I'll implement directly in memory -- I won't even use an in-memory database! Granted, this is a somewhat contrived example, but it illustrates more interesting integration tests.

A Simple In-Memory State-County Model

I'm going to move away from Test-Driven Development (TDD) and do what feels more natural to me when I'm writing fairly simple code -- just write the implementation and then write the tests to verify the implementation. I call this Test-Later Coding - TLC, hahaha. Here's the model, and note how I code specific exceptions in the model:

C#
namespace FluentWebApiIntegrationTestDemoModels
{
  public class StateModelException : Exception
  {
    public StateModelException() { } 
    public StateModelException(string msg) : base(msg) { }
  }

  public class County : List<string> { }

  public class StateModel
  {
    // Public for serialization
    public Dictionary<string, County> 
           StateCounties { get; set; } = new Dictionary<string, County>();

    public IEnumerable<string> GetStates()
    {
      var ret = StateCounties.Select(kvp => kvp.Key);

      return ret;
    }

    public IEnumerable<string> GetCounties(string stateName)
    {
      Assertion.That<StateModelException>
                (StateCounties.ContainsKey(stateName), "State does not exist.");

      return StateCounties[stateName];
    }

    public void AddState(string stateName)
    {
      Assertion.That<StateModelException>
                (!StateCounties.ContainsKey(stateName), "State already exists.");

      StateCounties[stateName] = new County();
    }
    
    public void AddCounty(string stateName, string countyName)
    {
      Assertion.That<StateModelException>
                (StateCounties.ContainsKey(stateName), "State does not exists.");
      Assertion.That<StateModelException>
      (!StateCounties[stateName].Contains(countyName), "County already exists.");

      StateCounties[stateName].Add(countyName);
    }
  }
}

We want to assert for expected conditions in the model, not the controller, so that the model may be re-used with all its validation.

The State Controller

Here's the controller:

C#
[ApiController]
[Route("[controller]")]
public class StateController : ControllerBase
{
  public static StateModel stateModel  = new StateModel();

  [HttpGet("")]
  public object GetStates()
  {
    var states = stateModel.GetStates();

    return Ok(states);
  }

  [HttpPost("")]
  public object AddState([FromBody] string stateName)
  {
    object ret = Try<StateModelException>(
      NoContent(), 
      () => stateModel.AddState(stateName));

    return ret;
  }

  [HttpPost("{stateName}/County")]
  public object AddCounty(
  [FromRoute, BindRequired] string stateName,
  [FromBody] string countyName)
  {
    object ret = Try<StateModelException>(
      NoContent(), 
      () => stateModel.AddCounty(stateName, countyName));

    return ret;
  }
}

Because I really don't like to repeat myself and I also don't like to clutter my code with try-catch blocks and if possible, if-else statements, I created a helper function that if we see the expected exception from the model, then return a bad request, otherwise throw the exception and let the framework return an internal server error. This code illustrates that most API methods should really be doing very simple things that have a limited set of exceptions. While more complicated API endpoints will have the possibility of throwing a variety of exceptions, I bring this up here more as a talking / discussion point than as a guidance. The point for me, when I teach software development / architecture, is to get people to think about questions they should be asking rather than just diving into robotic coding.

C#
private object Try<T>(object defaultReturn, Action action)
{
  object ret = defaultReturn;

  try
  {
    action();
  }
  catch (Exception ex)
  {
    if (ex.GetType().Name == typeof(T).Name)
    {
      ret = BadRequest(ex.Message);
    }
    else
    {
      throw;
    }
  }

  return ret;
}

New Fluent API Endpoint Methods

I've added three more fluent methods. With regards to the first one, it does seem a bit absurd to decouple the class definition that will hold the result from the method endpoint call. Yes, there are times when you want to deserialize only specific key-values of the return data, and then there are times like this, where it makes more sense to just have the fluent endpoint method "know" into what the model is that the data goes. Such is the case here.

C#
public static WorkflowPacket GetStatesAndCounties(this WorkflowPacket wp, string containerName)
{
  var resp = RestService.Get<StateModel>($"{wp.BaseUrl}/States");
  wp.LastResponse = resp.status;
  wp.Container[containerName] = resp.item;

  return wp;
}

public static WorkflowPacket AddState(this WorkflowPacket wp, string stateName)
{
  var resp = RestService.Post($"{wp.BaseUrl}/State", new { stateName });
  wp.LastResponse = resp.status;

  return wp;
}

public static WorkflowPacket AddCounty
(this WorkflowPacket wp, string stateName, string countyName)
{
   var resp = RestService.Post($"{wp.BaseUrl}/State/${stateName}/County", new { countyName });
  wp.LastResponse = resp.status;

  return wp;
}

And now, we need a Post method in our RestService:

C#
public static (HttpStatusCode status, string content) Post(string url, object data = null)
{
  var response = Execute(Method.POST, url, data);

  return (response.StatusCode, response.Content);
}

And of course, the Execute method must now support data in the request:

C#
private static IRestResponse Execute(Method method, string url, object data = null)
{
  var client = new RestClient(url);
  var request = new RestRequest(method);
  data.IfNotNull(() => request.AddJsonBody(data));
  var response = client.Execute(request);

  return response;
}

The Integration Tests

Now we can write positive and negative integration tests:

C#
[TestMethod]
public void AddStateTest()
{
  string baseUrl = "http://localhost/FluentWebApiIntegrationtestDemo";

  new WorkflowPacket(baseUrl)
    .AddState("NY")
    .IShouldSeeNoContentResponse()
    .AddState("CT")
    .IShouldSeeNoContentResponse()
    .GetStatesAndCounties("myStates")
    .IShouldSeeOKResponse()
    .ThenIShouldSee<StateModel>("myStates", m => m.GetStates().Count().Should().Be(2));
}

[TestMethod]
public void AddDuplicateStateTest()
{
  string baseUrl = "<a href="http://localhost/FluentWebApiIntegrationtestDemo">http://localhost/FluentWebApiIntegrationtestDemo</a>";

  new WorkflowPacket(baseUrl)
    .AddState("NY")
    .IShouldSeeNoContentResponse()
    .AddState("NY")
    .IShouldSeeBadRequestResponse();
}

[TestMethod]
public void AddCountyTest()
{
  string baseUrl = "<a href="http://localhost/FluentWebApiIntegrationtestDemo">http://localhost/FluentWebApiIntegrationtestDemo</a>";

  new WorkflowPacket(baseUrl)
    .AddState("NY")
    .IShouldSeeNoContentResponse()
    .AddCounty("NY", "Columbia")
    .GetStatesAndCounties("myStates")
    .IShouldSeeOKResponse()
    .ThenIShouldSee<StateModel>("myStates", m => m.GetStates().Count().Should().Be(1))
    .ThenIShouldSee<StateModel>("myStates", m => m.GetStates().First().Should().Be("NY"))
    .ThenIShouldSee<StateModel>("myStates", m => m.GetCounties("NY").Count().Should().Be(1))
    .ThenIShouldSee<StateModel>
     ("myStates", m => m.GetCounties("NY").First().Should().Be("Columbia"));
}

[TestMethod]
public void AddCountyNoStateTest()
{
  string baseUrl = "http://localhost/FluentWebApiIntegrationtestDemo";

  new WorkflowPacket(baseUrl)
    .AddCounty("NY", "Columbia")
    .IShouldSeeBadRequestResponse();
}

[TestMethod]
public void AddDuplicateCountyTest()
{
  string baseUrl = "http://localhost/FluentWebApiIntegrationtestDemo";

  new WorkflowPacket(baseUrl)
    .AddState("NY")
    .IShouldSeeNoContentResponse()
    .AddCounty("NY", "Columbia")
    .IShouldSeeNoContentResponse()
    .AddCounty("NY", "Columbia")
    .IShouldSeeBadRequestResponse();
}

Running the Tests

We have a problem with the simplest integration test, adding a state.

Image 22

Looking at the response in Postman, we see:

"The JSON value could not be converted to System.String. 
 Path: $ | LineNumber: 0 | BytePositionInLine: 1."

Oops. The reason is obvious -- I implemented the body parameter as a string, not a class. It also seems reasonable then to change not just the endpoint for adding the state, but also adding a county to the state:

C#
public class StateCountyName
{
  public string StateName { get; set; }
  public string CountyName { get; set; }
}

[HttpPost("")]
public object AddState([FromBody] StateCountyName name)
{
  object ret = Try<StateModelException>(
    NoContent(), 
    () => stateModel.AddState(name.StateName));

  return ret;
}

[HttpPost("County")]
public object AddCounty(
  [FromBody] StateCountyName name)
{
  object ret = Try<StateModelException>(
    NoContent(), 
    () => stateModel.AddCounty(name.StateName, name.CountyName));

  return ret;
}

Yes, we could add validation for null values and empty strings, but I'm not going to bore you with those details.

Now when I run the AddStateTest, I get back this nasty exception:

Newtonsoft.Json.JsonSerializationException: Cannot deserialize the current JSON array 
(e.g. [1,2,3]) into type 'FluentWebApiIntegrationTestDemoModels.StateModel' 
because the type requires a JSON object (e.g. {"name":"value"}) to deserialize correctly.

That's because I'm doing something stupid. The "get states" API function is returning a list of states, as strings, and we're expecting the response to be the StateModel. So let's fix that:

C#
[HttpGet("")]
public object GetStates()
{
  return Ok(stateModel);
}

Of course, the real problem here is that we shouldn't be exposing the internal dictionary of the StateModel but instead mapping this to a different collection. But that's besides the point for this article.

So now I see:

Image 23

Yay! But...

Image 24

Because:

C#
Expected wp.LastResponse to be NoContent because Did not expected "State already exists.", 
but found BadRequest.

Oops. For our testing, we need to reset our psuedo-database. Technically, this should be done in the cleanup of every test, which will actually be an API call:

C#
[TestCleanup]
public void CleanupData()
{
  string baseUrl = "http://localhost/FluentWebApiIntegrationtestDemo";

  new WorkflowPacket(baseUrl)
    .CleanupStateTestData()
    .IShouldSeeOKResponse();
}

Implemented as:

C#
[HttpPost("CleanupTestData")]
public object CleanupTestData()
{
  stateModel = new StateModel();

  return Ok();
}

However, this actually is the wrong way to do this, especially when debugging integration tests -- what we actually want is to clean up the test data before each test is run!

C#
[TestInitialize]
public void CleanupData()
{
  new WorkflowPacket(baseUrl)
    .CleanupStateTestData()
    .IShouldSeeOKResponse();
}

Finally we have success:

Image 25

Lastly, I'm getting tired of having this line:

C#
string baseUrl = "http://localhost/FluentWebApiIntegrationtestDemo";

in every single test. So instead, the test class will derive from a Setup class that can be extended to perform other setup/teardown functionality as well.

C#
public class Setup
{
  public static string baseUrl = "http://localhost/FluentWebApiIntegrationtestDemo";
}

This concept can be extended to parameterize the URL so that different servers (local, test, QA) can be used so that the integration tests can be performed at each step of the testing / deployment process. I often use the Setup base class to perform login/authentication as well as complex data setup (always by making endpoint calls!) that is used in multiple integration tests.

Review

Here, we've done something more interesting because the integration test requires more than one step. To add a county, the state must first exist. This basic test:

C#
new WorkflowPacket(baseUrl)
  .AddState("NY")
  .IShouldSeeNoContentResponse()
  .AddCounty("NY", "Columbia")
  .GetStatesAndCounties("myStates")
  .IShouldSeeOKResponse()
  .ThenIShouldSee<StateModel>("myStates", m => m.GetStates().Count().Should().Be(1))
  .ThenIShouldSee<StateModel>("myStates", m => m.GetStates().First().Should().Be("NY"))
  .ThenIShouldSee<StateModel>("myStates", m => m.GetCounties("NY").Count().Should().Be(1))
  .ThenIShouldSee<StateModel>
   ("myStates", m => m.GetCounties("NY").First().Should().Be("Columbia"));

could be extended to test that multiple states and multiple states per county are handled correctly, that updating and deleting names works, and so forth. If we were using a real DB, it would be reasonable for the API endpoints to return the record with the primary key field which could then be used to add counties, rather than specifying the state name. And if you have a consistent naming convention for your primary key (like "ID", why people insist on including the table name in the primary key name is beyond me), you can implement the fluent API methods to look up the object by its name, so you can write:

C#
.AddState("nyState", "NY")
.AddCounty("columbiaCounty", "nyState", "Columbia")

and the implementation would look something like:

C#
AddCounty(string countyBucketName, string stateBucketName, string countyName)
{
  int id = (wp.Container[stateBucketName] as IHasId).ID;
  var resp = RestService.Post<County>($"{wp.BaseUrl}/State/{id}/County", new { countyName });
  wp.LastResponse = resp.status;
  wp.LastContent = resp.content;
  wp.Container[countyBucketName] = resp.item;
}

Hopefully, that concept makes sense - the idea is to use the object returned by the API endpoint in further calls to the fluent API method, assuming you've gone about coding your models and endpoints with some intelligence.

The Drawback of Fluency

The problem with a fluent API (not the endpoint API!) is that if an exception occurs, you don't really know where you are in the chain of method calls. To help ameliorate this problem, we can implement a list of the method calls so that when a failure occurs, we can display to the developer where in the method chain the failure occurred. For example, each fluent API method can log itself:

C#
public static WorkflowPacket Factorial<T>
(this WorkflowPacket wp, string containerName, int n) where T: new()
{
  wp.Log("Factorial");
  ...

If we do this consistently, then we can display the log at any time:

C#
public static WorkflowPacket PrintLog(this WorkflowPacket wp)
{
  wp.CallLog.ForEach(item => wp.Write(item));

  return wp;
}

public static WorkflowPacket Write(this WorkflowPacket wp, string msg)
{
  System.Diagnostics.Debug.WriteLine(msg);

  return wp;
}

If I add PrintLog to the end of the integration test that adds a state and a county, we then see:

Image 26

However, that is not sufficient. What we really want is for the test cleanup to print out the log, so for a test failure, we can see where it fails. First, we refactor the test fixture itself to instantiate the WorkflowPacket for each test:

C#
private WorkflowPacket wp;

[TestInitialize]
public void InitializeTest()
{
  wp = new WorkflowPacket(baseUrl)
    .CleanupStateTestData()
    .IShouldSeeOKResponse();
}

[TestCleanup]
public void CleanupTest()
{
  wp.PrintLog();
}

and now every test uses wp instead of instantiating its own WorkflowPacket. So the test to create the state automatically looks like this:

C#
[TestMethod]
public void AddCountyAndAutoCreateStateTest()
{
  wp
    .AddCounty("NY", "Columbia")
    .IShouldSeeNoContentResponse()
    .GetStatesAndCounties("myStates")
    .IShouldSeeOKResponse()
    .ThenIShouldSee<StateModel>("myStates", m => m.GetStates().Count().Should().Be(1))
    .ThenIShouldSee<StateModel>("myStates", m => m.GetStates().First().Should().Be("NY"))
    .ThenIShouldSee<StateModel>("myStates", m => m.GetCounties("NY").Count().Should().Be(1))
    .ThenIShouldSee<StateModel>
     ("myStates", m => m.GetCounties("NY").First().Should().Be("Columbia"));
}

which, of course, fails and we can see on which step the test failed:

Image 27

And we see that it failed in the call to AddCounty.

Conclusion - What is the Pattern?

The pattern for this approach is simple enough and you can pretty much start anywhere for how you like to do things, which of course always depends on what the task to do actually is!

Image 28

Once you get into the habit of writing these kind of integration tests, it becomes second nature. I find that I actually want to write the integration test before touching any code:

  • First to prove that the code is wrong;
  • Second to prove that the fix works;
  • Third to prove that the fix didn't break something else.

I find this approach to also be much more efficient than using an actual web page test application that simulates the user's actions directly on the browser. With this approach, I can write the web APIs before the UI is ever implemented and have proof that the web API works according to the spec. Similarly, if my web API integration tests pass, then the problem is on the front-end.

Now you might say, well all this could be handled with unit testing. And I say no, it can't. In actual practice, I work with complex interdependent data, the code base was not designed to be unit testable (it never is) and the business rules are splattered across various class instances and trigger events. Testing any of this discretely does not build any confidence what-so-ever that when the user clicks on the "Save" button, that all the logic does what it's supposed to do. Conversely, with and integration test, I can set up all the different configurations of the data through other endpoints (which at the same time tests other parts of the code) and then call the "Save" API endpoint that triggers all the business rules. From there, I can request the data back as the user would see it and verify that everything looks correct.

And at the end of the day, the point of writing the integration tests in a fluent manner and using FluentAssertions is simply the feedback I keep getting: "wow, this is actually readable!" Hopefully, you'll have that experience as well. I also hope you've enjoyed reading how I created a fluent web API integration test "framework" and the steps and thinking that went into it.

History

  • 22nd May 2021: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Architect Interacx
United States United States
Blog: https://marcclifton.wordpress.com/
Home Page: http://www.marcclifton.com
Research: http://www.higherorderprogramming.com/
GitHub: https://github.com/cliftonm

All my life I have been passionate about architecture / software design, as this is the cornerstone to a maintainable and extensible application. As such, I have enjoyed exploring some crazy ideas and discovering that they are not so crazy after all. I also love writing about my ideas and seeing the community response. As a consultant, I've enjoyed working in a wide range of industries such as aerospace, boatyard management, remote sensing, emergency services / data management, and casino operations. I've done a variety of pro-bono work non-profit organizations related to nature conservancy, drug recovery and women's health.

Comments and Discussions

 
QuestionAdapting to .Net Framework Pin
#realJSOP19-May-22 0:47
mve#realJSOP19-May-22 0:47 
I am integrating your code into a .Net Framework MVC app, and had to make the following package change:

You must use RestSharp 106.n. Anything 107.0 and higher is for .Net Core.

Just a heads up for people coming after me that have the same .Net Framework compatibility requirement.

I'm not sure I can convert it to .NetFramework, though. Haven't yet been able to resolve missing Dictionary.Should method. It would be nice if the code automagically had code for MSTest, nUnit, and xUnit (.Net Core only?).
".45 ACP - because shooting twice is just silly" - JSOP, 2010
-----
You can never have too much ammo - unless you're swimming, or on fire. - JSOP, 2010
-----
When you pry the gun from my cold dead hands, be careful - the barrel will be very hot. - JSOP, 2013


modified 19-May-22 7:24am.

QuestionWhy I voted 5 Pin
Pete O'Hanlon23-May-21 7:09
mvePete O'Hanlon23-May-21 7:09 
AnswerRe: Why I voted 5 Pin
Marc Clifton24-May-21 8:48
mvaMarc Clifton24-May-21 8:48 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.