Introduction
It is obvious thing that during software creation life-cycle the code quality and proper functionality are the must. Besides of coding techniques like code reviews, Merge Requests/Pull Requests, pair programming, etc. various testing approaches are the must also. The QA teams or UAT tests are responsible for it at the end of coding process, but before it, the ones, who responsible for code quality are,
of course, developers.
An outcome of development is a “product” — properly working peace of software and in the era of micro-services, teams can decide about development approaches (Test-Driven-Development, Behavioral-Driven Development, Acceptance Test-Driven Development, etc.).
The “basic unit” for testing code is unit test. Coding techniques have changed during last years to make possible to cover you code (methods in a classes or units of code) by unit tests. Dependency injection and IoC containers is a “standard” (the techniques become more and more similar in .NET and Java ecosystems) while it makes also possible convenient unit testing.
Next step, is interaction testing of software components in a bigger functionality. For example, REST API method that reads data from another REST or asynchronously writes it to some Message Broker or Database. All methods used in this “process” are covered by Unit Tests, but such overall “cooperation” them starting from API entry point are not. And here developers start to write Integration Tests (or Integration Tests are already written, if you use TDD
or BDD
).
End to End tests (E2E
) — are the next degree of your software functional tests and it is very useful if they are automated during CI/CD
pipelines execution. But, of course, it depends :).
Load and stress testing that is another type of non functional tests that characterize the quality of you software. While it is not a part of this post let me redirect you here.
And a little bit more about Unit vs Integration vs System vs E2E Testing comparison from MS.
Azure AD authorization integration tests
Here I concentrate on integration tests of Web API example with Azure AD authorization, authentication and how to arrange it in a simple and readable way.
Lets imagine that we have CRUD API controller for some user notes for abstract documents. It is simply a title and description for document that user stores, something like this:
namespace Compentio.Ferragosto.Api.Controllers
{
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Identity.Web.Resource;
using Compentio.Ferragosto.Notes;
using System.Collections.Generic;
using System.Threading.Tasks;
using System;
[Authorize]
[ApiController]
[ApiConventionType(typeof(DefaultApiConventions))]
[RequiredScope(RequiredScopesConfigurationKey = "AzureAd:Scopes")]
[Route("api/notes")]
public class NotesController : ControllerBase
{
private readonly INotesService _notesService;
public NotesController(INotesService notesService)
{
_notesService = notesService;
}
/// <summary>
/// Returns list of notes
/// </summary>
/// <returns></returns>
[HttpGet]
public async Task<IEnumerable<Note>> Get()
{
return await _notesService.GetNotes().ConfigureAwait(false);
}
/// <summary>
/// Returns note by its id
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet("{id}")]
[ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Get))]
public async Task<Note> GetNote(string id)
{
return await _notesService.GetNote(id).ConfigureAwait(false);
}
/// <summary>
/// Method adds new note
/// </summary>
/// <param name="note"></param>
/// <returns></returns>
[HttpPost()]
[ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Post))]
public async Task<ActionResult<Note>> Add([FromBody] Note note)
{
return await _notesService.AddNote(note).ConfigureAwait(false);
}
/// <summary>
/// Method updates the note
/// </summary>
/// <param name="id"></param>
/// <param name="note"></param>
/// <returns></returns>
[HttpPut("{id}")]
[ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Put))]
public async Task<ActionResult<Note>> Update(string id, [FromBody] Note note)
{
var noteToUpdate = await _notesService.GetNote(id).ConfigureAwait(false);
if (noteToUpdate == null)
{
return NotFound();
}
var updatedNote = await _notesService.UpdateNote(note).ConfigureAwait(false);
return Accepted(updatedNote);
}
/// <summary>
/// Method deletes the note
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpDelete("{id}")]
[ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Delete))]
public async Task<IActionResult> Delete(string id)
{
await _notesService.DeleteNote(id).ConfigureAwait(false);
return Accepted();
}
}
}
The Azure Ad Scopes configured for access_as_user and access_as_admin. Web API applications here consists of three tiers: NotesControler
, NotesService
and NotesRepository
with MongoDB configured underneath.
The overall code you can find on the GitHub: Compentio.Ferragosto.Notes
The integration tests for getting notes method could be like this:
public class NotesApiIntegrationTests : IClassFixture<WebApplicationFactory<Api.Startup>>
{
private readonly WebApplicationFactory<Api.Startup> _factory;
private readonly NotesRepositoryMock _notesRepositoryMock;
private const string notesBaseUrl = "api/notes";
public NotesApiIntegrationTests(WebApplicationFactory<Api.Startup> factory)
{
_factory = factory;
_notesRepositoryMock = new();
}
[Fact]
public async Task ShouldReturnListOfNotes()
{
// Arrange
var httpClient = _factory.WithAuthentication()
.WithService(_ => _notesRepositoryMock.Object)
.CreateAndConfigureClient();
var mockedNotes = NotesMocks.Notes;
_notesRepositoryMock.MockGetNotes(mockedNotes);
// Act
var response = await httpClient.GetAsync(notesBaseUrl).ConfigureAwait(false);
var notes = await response.Content.ReadFromJsonAsync<IEnumerable<Note>>().ConfigureAwait(false);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType.ToString().Should().ContainAll("application/json; charset=utf-8");
notes.Should().BeEquivalentTo(mockedNotes);
}
[Fact]
public async Task ShouldBeUnauthorized()
{
// Arrange
var httpClient = _factory.WithService(_ => _notesRepositoryMock.Object)
.CreateAndConfigureClient();
// Act
var response = await httpClient.GetAsync(notesBaseUrl).ConfigureAwait(false);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
_notesRepositoryMock.Verify(notes => notes.GetNotes(), Times.Never());
}
[Fact]
public async Task ShouldBeForbidden()
{
// Arrange
var httpClient = _factory.WithAuthenticationWithoutClaims()
.WithService(_ => _notesRepositoryMock.Object)
.CreateAndConfigureClient();
// Act
Func<Task> getNotesTask = async () => { _ = await httpClient.GetAsync(notesBaseUrl).ConfigureAwait(false); };
// Assert
await getNotesTask.Should().ThrowAsync<System.Net.Http.HttpRequestException>();
_notesRepositoryMock.Verify(notes => notes.GetNotes(), Times.Never());
}
}
Few things here we should note:
-
WebApplicationFactory
— factory for bootstrapping an application in memory for functional and integration tests -
_httpClient
— HTTP client created using WebApplicationFactory extensions for authentication and mocking services — code snippets are below -
_notesRepositoryMock
— mocks for repository.
WebApplicationFactory
boostraps in memory HTTP web server, but for our scenario with authorization and authentication that’s not enough cause we need to check proper and invalid (unauthorized and unauthenticated) requests to our API. To cover it I’ve added WithAuthentication()
and WithAuthenticationWithoutClaims()
extensions methods for factory:
public static class WebApplicationFactoryExtensions
{
public static WebApplicationFactory<T> WithAuthentication<T>(this WebApplicationFactory<T> factory) where T : class
{
return AddAuthentication<T, TestAuthenticationHandler>(factory);
}
public static WebApplicationFactory<T> WithAuthenticationWithoutClaims<T>(this WebApplicationFactory<T> factory) where T : class
{
return AddAuthentication<T, TestAuthenticationHandlerWithoutClaims>(factory);
}
private static WebApplicationFactory<T> AddAuthentication<T, TAuthHandler>(this WebApplicationFactory<T> factory)
where T : class
where TAuthHandler : TestAuthenticationHandlerBase
{
return factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TAuthHandler>("Test", options => { });
});
});
}
public static WebApplicationFactory<TFactory> WithService<TFactory, TService>(this WebApplicationFactory<TFactory> factory, Func<IServiceProvider, TService> implementationFactory)
where TService : class
where TFactory : class
{
return factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddTransient(implementationFactory);
});
});
}
public static HttpClient CreateAndConfigureClient<T>(this WebApplicationFactory<T> factory) where T : class
{
var client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Test");
return client;
}
}
These methods use different AuthenticationHandlers
: TestAuthenticationHandler
for proper authentication and TestAuthenticationHandlerWithoutClaims
for user, that does not have required claims. The handlers are below:
public class TestAuthenticationHandler : TestAuthenticationHandlerBase
{
public TestAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override Claim[] CreateClaims() => new[]
{
new Claim(ClaimTypes.Name, "Test user"),
new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()),
new Claim("http://schemas.microsoft.com/identity/claims/scope", "access_as_user")
};
}
and TestAuthenticationHandlerWithoutClaims
looks like:
public class TestAuthenticationHandlerWithoutClaims : TestAuthenticationHandlerBase
{
public TestAuthenticationHandlerWithoutClaims(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override Claim[] CreateClaims() => new[]
{
new Claim(ClaimTypes.Name, "Test user")
};
}
and TestAuthenticationHandlerBase
is:
public abstract class TestAuthenticationHandlerBase : AuthenticationHandler<AuthenticationSchemeOptions>
{
protected TestAuthenticationHandlerBase(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected abstract Claim[] CreateClaims();
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = CreateClaims();
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
var result = AuthenticateResult.Success(ticket);
return Task.FromResult(result);
}
}
here it is important in TestAuthenticationHandler
to use claim type as “http://schemas.microsoft.com/identity/claims/scope” for Azure AD access_as_user scope
. In the TestAuthenticationHandlerWithoutClaims
we do not add required in NotesController
access_as_user
scope and in test case ShouldBeForbidden
(in NotesApiIntegrationTests
) we expect Forbidden response.
ShouldBeUnauthorized()
method does not configured to use any authentication handler, thus we expect Unauthorized 401 error and, obviously, no any interaction with _notesRepository
:
// Arrange
var httpClient = _factory.WithService(_ => _notesRepositoryMock.Object)
.CreateAndConfigureClient();
// Act
var response = await httpClient.GetAsync(notesBaseUrl).ConfigureAwait(false);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
_notesRepositoryMock.Verify(notes => notes.GetNotes(), Times.Never());
Summary
Here I've covered three basic auth scenarios for API requests of one GET method:
- 200 OK,
- 401 Unauthorized
-
403 Forbidden.
From my experience, it is important always to keep such tests in code and execute them during
CI/CD
pipelines, since it prevents us from accidental (which, unfortunately, occurs often) removal of Authorize or RequiredScope controller attributes. Obviously, in a real web apps integration testing scenarios, more test needed: e.g. validation tests (BadRequest), Server unexpected errors (500), empty lists or NotFound for one entity, ect.
In the post I’d like to show how we use WebApplicationFactory to arrange integration tests for different authorization scenarios. For testing and mocks I’ve used:
- xUnit
- Moq
- Fluent Assertions
- Bogus For mocking also I propose to use “Fluent Mocking” like this:
public class NotesServiceMock : Mock<INotesService>
{
public NotesServiceMock MockGetNotes(IEnumerable<Note> notes)
{
Setup(service => service.GetNotes()).ReturnsAsync(notes);
return this;
}
public NotesServiceMock MockGetNote(string noteId, Note note)
{
Setup(service => service.GetNote(It.Is<string>(i => i == noteId))).ReturnsAsync(note);
return this;
}
public NotesServiceMock MockAddNote(Note note)
{
note.Id = Guid.NewGuid().ToString();
Setup(service => service.AddNote(note)).ReturnsAsync(note);
return this;
}
public NotesServiceMock MockModifyNote(Note note)
{
Setup(service => service.GetNote(It.Is<string>(i => i == note.Id))).ReturnsAsync(note);
return this;
}
}
Thank you and pleasure coding!
Top comments (0)