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
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:
- It simplifies the test setup process.
- It more closely simulates what the user might do or what the front-end application does for the user.
- 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.
- 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.
- 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.
- The concept integrates well with Fluent Assertions, which we'll use here.
The concept is very simple:
- We have an integration test suite (actually implemented using the unit test framework)...
- ...that calls methods in our "
fluent
" library... - ...which calls the desired endpoints...
- ...and we can capture the results.
The last part "we can capture the results" is the interesting part, as we want to capture:
- The resulting HTTP status code and text.
- The returned JSON (yes, I'm assuming everything is going to be JSON).
- Deserialize the JSON if there are no errors in the call.
- 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."
We'll create a new VS 2019 project:
and select:
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:
which I'm going to rename and gut, so it looks simply like this:
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:
and will launch IIS so I don't have to deal with port silliness:
We can then run the project (VS will provision IIS the first time, which is awesome), and we see:
Great!
Next, I'll add an MSTest Test Project (.NET Core) - yes, I'm using the unit test framework to perform integration tests.
Creating the integration test project resulted in a nightmare of errors:
The only "solution" I found was to put the web API project and the integration test project side-by-side:
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:
to:
So finally that builds.
The initial UnitTest1.cs file that VS creates, I've renamed to "DemoTests
" and this stub looks like this:
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.
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:
which at the moment contains just a stub file:
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.
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:
namespace WorkflowTestMethods
{
public class ApiMethods
{
}
}
After setting up a couple project references, we can write our first fluent integration test:
string baseUrl = "<a href="http:
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:
- The fluent methods are extensions on the
WorkflowPacket
- Each fluent method returns the
WorkflowPacket
instance.
Refactoring the WorkflowPacket
class:
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
(I also added the FluentAssertions
package) class:
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.
We see that the integration test failed (obviously, because we're not calling the endpoint yet):
The amazing thing about FluentAssertions
is that it tells you exactly what the issue is:
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:
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:
public static WorkflowPacket Home(this WorkflowPacket wp, string controller)
{
var resp = RestService.Get($"{wp.BaseUrl}/{controller}");
wp.LastResponse = resp.status;
return wp;
}
The test still fails, but now we see why:
So the last step is to refactor the endpoint so it doesn't throw a NotImplementedException
exception but instead returns OK
:
[HttpGet]
public object Get()
{
return Ok();
}
And finally the test passes!
What have we accomplished? Given this simple example:
new WorkflowPacket(baseUrl)
.Home("Demo")
.IShouldSeeOKResponse();
We have created the basic framework for:
- Calling endpoints
- Verifying status returns
Let's extend this further now to work with more "real" API endpoints.
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:
[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:
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 Newtonsoft.Json
package, we implement:
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;
}
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.
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.
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:
[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:
So let's actually implement the factorial computation:
[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:
Given this integration test:
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:
- Make an API endpoint call with a query parameter.
- Deserialize the result.
- Verify the result.
We also implemented a simple integration test that verifies that the API gracefully handles bad input with a simple workflow:
new WorkflowPacket(baseUrl)
.Factorial<FactorialResult>("factResult", -1)
.IShouldSeeBadRequestResponse();
If we wanted to use the dynamic feature of C# (though we lose Intellisense), we could, with a slightly different workflow method, write:
.ThenIShouldSee("factResult", r => r.Result.Should().Be(720));
Except that we get an exception from the runtime binder:
We could implement a dynamic ThenIShouldSee
like this:
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:
.ThenIShouldSee("factResult", r => r.Result == 720M);
but then look what happens:
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!
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.
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:
namespace FluentWebApiIntegrationTestDemoModels
{
public class StateModelException : Exception
{
public StateModelException() { }
public StateModelException(string msg) : base(msg) { }
}
public class County : List<string> { }
public class StateModel
{
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.
Here's the controller:
[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.
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;
}
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.
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
:
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:
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;
}
Now we can write positive and negative integration tests:
[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:
new WorkflowPacket(baseUrl)
.AddState("NY")
.IShouldSeeNoContentResponse()
.AddState("NY")
.IShouldSeeBadRequestResponse();
}
[TestMethod]
public void AddCountyTest()
{
string baseUrl = "<a href="http:
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();
}
We have a problem with the simplest integration test, adding a state.
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
:
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 string
s, and we're expecting the response to be the StateModel
. So let's fix that:
[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:
Yay! But...
Because:
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:
[TestCleanup]
public void CleanupData()
{
string baseUrl = "http://localhost/FluentWebApiIntegrationtestDemo";
new WorkflowPacket(baseUrl)
.CleanupStateTestData()
.IShouldSeeOKResponse();
}
Implemented as:
[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!
[TestInitialize]
public void CleanupData()
{
new WorkflowPacket(baseUrl)
.CleanupStateTestData()
.IShouldSeeOKResponse();
}
Finally we have success:
Lastly, I'm getting tired of having this line:
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.
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.
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:
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:
.AddState("nyState", "NY")
.AddCounty("columbiaCounty", "nyState", "Columbia")
and the implementation would look something like:
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 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:
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:
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:
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:
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:
[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:
And we see that it failed in the call to AddCounty
.
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!
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.
- 22nd May 2021: Initial version