Sunday, February 11, 2024

Unit testing cheat sheet (xUnit, FakeItEasy, FluentAssertions)

Summary: Samples of C# code handling common unit testing tasks using xUnit, FakeItEasy, and FluentAssertions frameworks.

Here are some tips, samples, and suggestions for implementing common unit testing tasks.

Unit test without parameters:

[Fact]
void SampleClassName_MethodToBeTested_DescriptiveTestTitle()
{
    // ARRANGE
    // ACT
    // ASSERT
}

Unit test with parameters:

[Theory]
[InlineData("abc", 1, true)]
[InlineData("xyz", 2, false)]
void SampleClassName_MethodToBeTested_DescriptiveTestTitle
(
    string? param1,
    int? param2,
    bool? param3
)
{
    // ARRANGE
    // ACT
    // ASSERT
}

Unit tests with DateTime or DateTimeOffset parameters:

[Theory]
[InlineData("2024-06-05 23:45:10.456", "3/28/2007 12:13:50 PM -07:00")]
void SampleClassName_MethodToBeTested_DescriptiveTestTitle
(
    string? paramDateTime,
    string? paramDateTimeOffset
)
{
    DateTime dateTime = DateTime.Parse(paramDateTime);
    DateTime dateTimeOffset = DateTimeOffset.Parse(paramDateTimeOffset);
    ...
}

Unit tests with complex parameters:

[Theory]
[InlineData("{\"id\":123,\"email\":\"joe.doe@somemail.com\",\"enabled\":true}")]
void SampleClassName_MethodToBeTested_DescriptiveTestTitle
(
    string? paramUser
)
{
	// This example uses Newtonsoft.Json, but the same can be done
    // using the default framework's JSON serializer.
    User? user = JsonConvert.DeserializeObject<User?>(paramUser);
    
    // NOTE: Yes, I know about MemberData, but this seems more straightforward IMHO.
    ...
}

A fake object with the default constructor (can use an interface or a class):

ISample fakeISample = A.Fake<ISample>();
Sample fakeSample = A.Fake<Sample>();
ISample<AnotherSample> fakeGenericSample = A.Fake<ISample<AnotherSample>>();

A fake object with a parametrized constructor:

Sample fakeSample = A.Fake<Sample>(x => x.WithArgumentsForConstructor(new object[] { "param1", 2, true }));

A fake object returns specific property values:

User user = A.Fake<User>();

A.CallTo(() => user.Id).Returns(12345);
A.CallTo(() => user.Email).Returns("joe.doe@somemail.com");
A.CallTo(() => user.Enabled).Returns(true);

A fake object returns a specific method result:

// Data service is used by user service to get user from database
// and we are faking it.
IDataService dataService = A.Fake<IDataService>();

// Define properties of the user object to be retuned.
int id = 12345;

// Let's assume that the method of the UserService class being tested 
// internally calls the GetUserById method of the IDataService object
// (we're using a fake here to simulate a valid return).
A.CallTo(() => dataService.GetUserById(id)).Returns(new User(id));

// UserService is the class we're testing (system under test or SUT).
UserService userService = new UserService(dataService);

// We are testing the Enable method and expect it to be successful.
userService.Enable(id);

Use wildcard to trigger a fake method result for any parameter value:

// A<T>._ is a shortcut for a wildcard.
A.CallTo(() => dataService.GetUserById(A<string>._)).Returns(existingUser);

A fake object returns a specific value from an async method:

IDataService dataService = A.Fake<IDataService>();

// Define properties of the user object to be retuned.
int id = 12345;

// Assume that GetUserById is an async method returning Task<User>.
A.CallTo(() => dataService.GetUserById(A<string>._)).Returns(Task.FromResult(new User(id)));

A fake object returns a specific value from a generic method:

// Data service is used by user service to get user from database
// and we are faking it.
IDataService dataService = A.Fake<IDataService>();

// Define properties of the user object to be retuned.
int id = 12345;

// Data service has a generic method GetUser that we want to fake.
A.CallTo(dataService).Where(call => call.Method.Name == "GetUser")
   .WithReturnType<User>()
   .Returns(new User(id));
   
// UserService is the class we're testing (system under test or SUT).
UserService userService = new UserService(dataService);

Force a fake method to throw an exception:

A.CallTo(() => dataService.GetUserById(A<string>._)).Throws<Exception>();
// Equivalent to:
A.CallTo(() => dataService.GetUserById(A<string>._)).Throws(new Exception());

Expect a method to throw an exception of the exact type:

Assert.Throws<InvalidInputException>(() => userService.UpdateUser(user));

Expect a method to throw any exception derived from the specific type:

Assert.ThrowsAny<InvalidInputException>(() => userService.UpdateUser(user));

Assign expected exception to a variable:

Exception ex = Assert.Throws<InvalidInputException>(() => userService.UpdateUser(user));

Set up a fake SendGrid call:

ISendGridClient _sendGridClient = A.Fake<ISendGridClient>();

System.Net.Http.HttpResponseMessage httpResponse = new();
System.Net.Http.HttpContent         httpContent  = httpResponse.Content;
System.Net.Http.HttpResponseHeaders httpHeaders  = httpResponse.Headers;

httpHeaders.Add("X-Message-Id", "12345");

SendGrid.Response sendGridResponse = 
    A.Fake<SendGrid.Response>(x => x
        .WithArgumentsForConstructor(new object[] { httpStatusCode, httpContent, httpHeaders }));

A.CallTo(() =< _sendGridClient
    .SendEmailAsync(A<SendGridMessage>._, A<CancellationToken>._))
    .Returns(Task.FromResult(sendGridResponse));

Mock HttpContext for a controller class under test:

System.Net.Http.HttpRequest httpRequest = A.Fake<HttpRequest>();
System.Net.Http.HttpContext httpContext= A.Fake<HttpContext>();

A.CallTo(() => httpContext.Request).Returns(httpRequest);

// Set up the request properies that you need.
A.CallTo(() => httpRequest.Scheme).Returns("https");
A.CallTo(() => httpRequest.Host).Returns(new HostString("localhost:8888"));
A.CallTo(() => httpRequest.PathBase).Returns(new PathString("/api/v1"));
A.CallTo(() => httpRequest.Path).Returns(new PathString("sample"));

// SampleController is derived from the ControllerBase class.
SampleController controller = new(...);

controller.ControllerContext =  new ControllerContext()
{
	HttpContext = httpContext
};

Controller method GET returns HTTP status code 200 OK:

// Assume that all dependencies have been set.
ActionResult<User> actionResult = controller.GetUser("1234567890");

// First, test action result.
actionResult.Should().NotBeNull();
actionResult.Result.Should().NotBeNull();
actionResult.Result.Should().BeAssignableTo<OkObjectResult>();

// Next, test response specific result.
OkObjectResult? result = actionResult.Result as OkObjectResult;

Controller method POST returns HTTP status code 201 Created:

// Assume that all dependencies have been set.
ActionResult<User> actionResult = controller.CreateUser(user);

// First, test action result.
actionResult.Should().NotBeNull();
actionResult.Result.Should().NotBeNull();
actionResult.Result.Should().BeAssignableTo<CreatedResult>();

// Next, test response specific result.
CreatedResult? result = actionResult.Result as CreatedResult;
result.Should().NotBeNull();

// Successful POST must return the URL of the GET method 
// ending with the ID of the newly created object in the
// Location header.
result?.Location.Should().NotBeNull();
result?.Location.Should().EndWith(id);

Controller method PATCH returns HTTP status code 204 No Content:

// Assume that all dependencies have been set.
ActionResult actionResult = controller.UpdateUser(user);

// First, test action result.
actionResult.Should().NotBeNull();
actionResult.BeAssignableTo<NoContentResult>();

Controller method POST returns HTTP status code 400 Bad Request:

// Assume that all dependencies have been set.
ActionResult<User> actionResult = controller.CreateUser(user);

// First, test action result.
actionResult.Should().NotBeNull();
actionResult.Result.Should().NotBeNull();
actionResult.Result.Should().BeAssignableTo<BadRequestObjectResult>();

// Next, test response specific result.
BadRequestObjectResult? result = actionResult.Result as BadRequestObjectResult;
result.Should().NotBeNull();

// Finally, check the error object to be returned to consumer.
// This example shows a custom problem details object ErroDetails,
// which may be different in your case.
result?.Value.Should().NotBeNull();
result?.Value.Should().BeAssignableTo<ErrorDetails>();

if (result?.Value is ErrorDetails errorDetails)
{
    // ServiceCodeType is a custom enum value returned via the error object's 
    // ServiceCode property (this check may be different in your case).
    errorDetails.ServiceCode.Should().NotBeNull();  
    errorDetails.ServiceCode.Should().Be(ServiceCodeType.BadRequest.ToString());
}

Controller method POST returns HTTP status code 401 Unauthorized:

// Assume that all dependencies have been set.
ActionResult<User> actionResult = controller.CreateUser(user);

// First, test action result.
actionResult.Should().NotBeNull();
actionResult.Result.Should().NotBeNull();
actionResult.Result.Should().BeAssignableTo<UnauthorizedObjectResult>();

// Next, test response specific result.
UnauthorizedObjectResult? result = actionResult.Result as UnauthorizedObjectResult;
result.Should().NotBeNull();

// See example handling 400 Bad Request.

Controller method PATCH returns HTTP status code 404 Not Found:

// Assume that all dependencies have been set.
ActionResult<User> actionResult = controller.UpdateUser(user);

// First, test action result.
actionResult.Should().NotBeNull();
actionResult.Result.Should().NotBeNull();
actionResult.Result.Should().BeAssignableTo<NotFoundObjectResult>();

// Next, test response specific result.
NotFoundObjectResult? result = actionResult.Result as NotFoundObjectResult;
result.Should().NotBeNull();

// See example handling 400 Bad Request.

Controller method POST returns HTTP status code 409 Conflict:

// Assume that all dependencies have been set.
ActionResult<User> actionResult = controller.CreateUser(user);

// First, test action result.
actionResult.Should().NotBeNull();
actionResult.Result.Should().NotBeNull();
actionResult.Result.Should().BeAssignableTo<ConflictObjectResult>();

// Next, test response specific result.
ConflictObjectResult? result = actionResult.Result as ConflictObjectResult;
result.Should().NotBeNull();

// See example handling 400 Bad Request.

Mock AppSettings:

// The following dictionary mimics appsettings.json file.
// Notice how array values must be defined using indexes.
Dictionary<string,string?> configSettings = new()
{
    {"ServiceA:ValueSettingX", "ValueX"},
    {"ServiceA:ValueSettingY", "ValueY"},
    {"ServiceA:ValueSettingZ", "ValueZ"},
    {"ServiceA:ArraySetting1:0", "Value0"},
    {"ServiceA:ArraySetting1:1", "Value1"},
    {"ServiceA:ArraySetting1:2", "Value2"},
}

IConfiguration config = new ConfigurationBuilder()
    .AddInMemoryCollection(configSettings)
    .Build();

Common FluentAssertions:

// Value should not be null.
value.Should().NotBeNull();

// Value should be of specific type.
value.Should().BeOfType<User>();

// Value should be equal to.
value.Should().Be(1);
value.Should().Be(true);
value.Should().Be("expected value");

// Value should contain (comparison is case sensitive).
value.Should().Contain("value");

// Value should contain any one of the specified values (comparison is case sensitive):
value.Should().ContainAny("value1", "value2");

// Value should contain all of the specified values (comparison is case sensitive):
value.Should().ContainAll("value1", "value2");

// String value should be equal to (comparison is case insensitive).
value.Should().BeEquivalentTo("Value");