Functional Testing for ASP.NET Core API

Functional testing is the next level above the unit testing and gives us confidence that the code we wrote works as intended. If we are talking about functional testing in the scope of ASP.NET, we mean how we test our API.

Functional testing

What actually functional testing is?

Functional testing is the process through which we determine if a piece of software is acting following pre-determined requirements. It uses black-box testing techniques, in which the tester does not know the internal system logic.

In the scope of API, we need to prepare possible requests that we expect to get and be sure that endpoints return a proper response for any payload.

Should we even spend time on it?

Definitely! If you don`t write integrational tests, then functional testing is a solution to ensure that your code has fewer bugs than it could have. When you can write integrational tests as well, it should be simple to reuse a shared codebase for functional and integrational tests. One difference will be in the proper environmental setup.

Let`s define a simple API

Our controller contains only single action to create some entity for test purposes. Our business rules may require any entity, which does not matter for our test example. Besides, we should remember about authentication nowadays. Security compliance requires protecting API with any authentication. Let`s assume that the current API requires an Authorization header with a Bearer token.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[ApiController]
public class MyController : ControllerBase
{
private readonly IService _service;

public MyController(IService service)
{
_service = service;
}

[HttpPost("entities")]
[ProducesResponseType(typeof(CreateResponse), (int)HttpStatusCode.OK)]
[ProducesResponseType(typeof(ExceptionResponse), (int)HttpStatusCode.BadRequest)]
public Task<CreateResponse> CreateAsync([FromBody] CreateRequest request)
{
return _service.CreateAsync(request);
}
}

What will the functional test look like for this controller?

In our test project, I used NUnit as a test framework, but it is up to you to choose which better suits your needs. NUnit has all the required functionality to write good tests. Also, it`s pretty popular on the market.

Let`s add the following packages to the test project:

1
2
dotnet add package NUnit
dotnet add package NUnit3TestAdapter

Take a time to read about test frameworks in .NET:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
[TestFixture]
public class ApiControllerTests
{
private const string CreateUrl = "/entities";

private readonly TokenBuilder _tokenBuilder = new();

private TestServer? _server;
private HttpClient? _client;

[SetUp]
public void Setup()
{

var settings = new Settings
{
SymmetricFuncTestKey = "your key for functional tests"
};

var builder = new WebHostBuilder()
.UseStartup<Startup>()
.ConfigureTestServices(services =>
{
// Mock your external services here
})
.ConfigureServices(services =>
{
// Change authentication to use bearer token which signed by symmetric key
services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
IssuerSigningKey =
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.SymmetricFuncTestKey)),
ValidateIssuerSigningKey = true,
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidIssuer = TestConstants.Issuer,
ValidAudience = TestConstants.Audience,
};
});
});
_server = new TestServer(builder);
_client = _server.CreateClient();
}

[Test]
public async Task CreateAsync_Should_ReturnNotAuthenticated_When_RequestWithoutAuthToken()
{
// Arrange
var request = new CreateRequest
{
PropertyName = "propertyValue"
};
var json = JsonConvert.SerializeObject(request);
using var data = new StringContent(json, Encoding.UTF8, "application/json");

// Act
var response = await _client?.PostAsync(CreateUrl, data)!;

// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}

[Test]
public async Task CreateAsync_Should_Return400_When_RequestValidationFailed()
{
// Arrange
var request = new CreateRequest();
var json = JsonConvert.SerializeObject(request);
using var data = new StringContent(json, Encoding.UTF8, "application/json");
var token = await _tokenBuilder.BuildJWTToken();
_client?.DefaultRequestHeaders.Add("Authorization", "Bearer " + token);

// Act
var response = await _client?.PostAsync(CreateUrl, data)!;

// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}

[Test]
public async Task CreateAsync_Should_ReturnCreateResponse()
{
// Arrange
var request = new CreateRequest
{
PropertyName = "propertyValue"
};
var json = JsonConvert.SerializeObject(request);
using var data = new StringContent(json, Encoding.UTF8, "application/json");
var token = await _tokenBuilder.BuildJWTToken();
_client?.DefaultRequestHeaders.Add("Authorization", "Bearer " + token);

var expected = new CreateResponse
{
PropertyName = "propertyValue"
};

// Act
var response = await _client?.PostAsync(CreateUrl, data)!;

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var responseBody = await response.Content.ReadAsStringAsync();
var responseObject = JsonConvert.DeserializeObject<CreateResponse>(responseBody);
responseObject.Should().BeEquivalentTo(expected);
}
}

We should cover that API endpoint protected by authentication, and clients can not request it without a proper token. To do this, we need to prepare a test server with a proper authentication setup. To do it in Startup, we need to override JwtBearerOptions with our test authentication, which relies on auth token signed by SymmetricSecurityKey. To comply with security rules, we should avoid storing any secrets in code, and I don`t suggest you violate this rule even for test projects.

When the authentication configuration is ready, we can generate the proper token to execute business logic in test cases. We can separate token generation into TokenBuilder and use it to get auth token.

Conclusion

I hope you feel that functional tests are pretty simple and allow you to verify your API each time something changes. We often include functional tests as a step in your build pipeline, and it gives you confidence that everything works well before deploying to the production environment.