60 - Testing
Why test
- Until you execute a line of code, you don't know if that line can work at all.
- Until you execute your code with a representative set of basic use cases, you don't know if the code can work end-to-end.
- If you and possibly other people are going to modify your code, it's very easy to break it in unexpected ways. A suite of automated unit and integration tests gives you confidence you've not broken anything significant.
- During defense you will be asked to explain your test strategy — what you test, why, and how. Writing tests is not enough; you must understand them.
Different ways to test
- Functional
- Unit testing
- Integration testing
- System testing
- Acceptance testing
- Non-functional
- Load/Stress testing
- Security testing
- Usability testing
- Compatibility testing
- Manual/Explorative testing, Black/White box, UI tests, ....

The testing pyramid
/ E2E \ Few, slow, expensive
/----------\
/ Integration \ Some, moderate speed
/----------------\
/ Unit tests \ Many, fast, cheap
/____________________\
- Unit tests — fast, isolated, test individual components. Write the most of these.
- Integration tests — test multiple components together (e.g., controller + database + DI). Slower, but verify real interactions.
- E2E (end-to-end) tests — test the entire application through the UI or API as a real user would. Slowest, most brittle, use sparingly for critical paths.
- Each level up is slower and more expensive to maintain — invest accordingly.
Unit testing
- A unit test is a test that exercises individual software components or methods, also known as "unit of work".
- Unit tests should only test code within the developer's control.
- They do not test infrastructure concerns.
- Infrastructure concerns include interacting with databases, file systems, and network resources.
Integration testing
- An integration test differs from a unit test in that it exercises two or more software components' ability to function together, also known as their "integration".
- These tests operate on a broader spectrum of the system under test, whereas unit tests focus on individual components.
- Often, integration tests do include infrastructure concerns.
Load/stress testing
- A load test aims to determine whether or not a system can handle a specified load, for example, the number of concurrent users using an application and the app's ability to handle interactions responsively.
- Load tests: Test whether the app can handle a specified load of users for a certain scenario while still satisfying the response goal. The app is run under normal conditions.
- Stress tests: Test app stability when running under extreme conditions, often for a long period of time. The tests place high user load, either spikes or gradually increasing load, on the app, or they limit the app's computing resources.
- Stress tests determine if an app under stress can recover from failure and gracefully return to expected behaviour. Under stress, the app isn't run under normal conditions.
Test frameworks and project setup
- xUnit, NUnit, MSTest, ...
- xUnit is a free, open source, community-focused unit testing tool for .NET. Written by the original inventor of NUnit v2.
- xUnit works with all major .NET IDEs (Rider, Visual Studio, VS Code)
Running tests from CLI
# run all tests in solution
dotnet test
# run with detailed output
dotnet test --verbosity normal
# filter by test name
dotnet test --filter "FullyQualifiedName~UnitTest"
# filter by class name
dotnet test --filter "ClassName=WebApp.Tests.UnitTestHomeController"
xUnit vs NUnit
- We will have a test class and lots of test methods in them
- NUnit creates instance of the test class and runs all the test methods
- xUnit creates new instance of the test class for every test method
xUnit
- Add new project to solution
- Category: Unit Test Project
- Type: xUnit

test csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp.Io" Version="1.0.0" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\WebApp\WebApp.csproj" />
</ItemGroup>
</Project>
WebApp csproj additions
<ItemGroup>
<InternalsVisibleTo Include="WebApp.Tests" />
</ItemGroup>
xUnit.runner.json
{
"shadowCopy": false
}
Unit testing fundamentals
- A typical unit test contains 3 phases
- Initializes a small piece of an application it wants to test (also known as the system under test, or SUT).
- Applies some stimulus to the system under test (usually by calling a method on it)
- Observes the resulting behavior. If the observed behavior is consistent with the expectations, the unit test passes, otherwise, it fails, indicating that there is a problem somewhere in the system under test.
- These three unit test phases are also known as Arrange, Act and Assert, or simply AAA.
Test naming conventions
Use a consistent naming pattern so test names describe what is being tested:
MethodName_Scenario_ExpectedResult
Examples:
GetById_WithValidId_ReturnsEntityCreate_WithDuplicateName_ThrowsValidationExceptionDelete_WhenNotAuthorized_Returns403
What makes a good test
- Deterministic — same input always produces same result, no randomness or timing dependencies
- Isolated — tests don't depend on each other or on shared mutable state
- Fast — unit tests should run in milliseconds
- Readable — a failing test name + assertion message should tell you what broke without reading the implementation
xUnit test methods and assertions
Test methods
- Test methods are declared using attribute
[Fact] - Test methods will do Act and Assert steps
[Fact]
public async Task TestAction_ReturnsVm()
{
// ACT
var result = await _homeController.Test() as ViewResult;
// ASSERT
Assert.NotNull(result);
Assert.NotNull(result!.Model);
Assert.IsType<TestViewModel>(result.Model);
}
Seeding test data
- More complex behaviour testing — with data
private async Task SeedDataAsync()
{
_ctx.ContactTypes.Add(new ContactType()
{
ContactTypeDescription = new AppLangString("Skype"),
ContactTypeValue = new AppLangString("Skype is still used?")
});
await _ctx.SaveChangesAsync();
}
Testing with seeded data
- Arrange
- Act
- Assert
[Fact]
public async Task TestAction_ReturnsVm_WithData()
{
await SeedDataAsync();
// ACT
var result = await _homeController.Test() as ViewResult;
// ASSERT
Assert.NotNull(result);
Assert.NotNull(result!.Model);
Assert.IsType<TestViewModel>(result.Model);
var vm = result.Model as TestViewModel;
Assert.NotNull(vm!.ContactTypes);
Assert.Equal(1, vm!.ContactTypes!.Count);
}
Assertion methods
- Equal, NotEqual
- Null, NotNull,
IsType<SomeType>,IsNotType<>, Same, NotSame - StartsWith, EndsWith
- Matches, DoesNotMatch — regex
- True, False
- Contains, DoesNotContain
Assert.Contains("text", "This is a text.");
var names = new List<string> { "Picard", "Kirk" };
Assert.Contains(names, n => n == "Kirk");
- Empty, NotEmpty
- InRange, NotInRange
Asserting exceptions
No asserts for "no exception"
// exact exception type
Assert.Throws<NullReferenceException>(() => throw new NullReferenceException());
// exact or derived exception type
Assert.ThrowsAny<Exception>(() => throw new ApplicationException());
// async version
await Assert.ThrowsAsync<InvalidOperationException>(
async () => await _service.DoSomethingAsync());
FluentAssertions
-
FluentAssertions — makes assertions read like human language
-
Nuget: FluentAssertions
-
Shouldly, others
-
using FluentAssertions;
[Fact]
public async Task TestAction_ReturnsVm_WithData_Fluent()
{
await SeedDataAsync();
// ACT
var result = await _homeController.Test() as ViewResult;
result.Should().NotBeNull();
result!.Model.Should().NotBeNull();
result.Model.Should().BeOfType<TestViewModel>();
(result.Model as TestViewModel)!
.ContactTypes.Should().NotBeNull().And.HaveCount(1);
}
Parameterized tests
-
[Fact]— parameterless unit test, tests invariants of code -
[Theory]— parameterized unit test -
Supply parameters with
[InlineData(param1, param2, ...)]
[Theory]
[InlineData(1)]
[InlineData(10)]
public async Task TestAction_ReturnsVm_WithDataCounts_Fluent(int count)
{
await SeedDataAsync(count);
// ACT
var result = await _homeController.Test() as ViewResult;
result.Should().NotBeNull();
result!.Model.Should().NotBeNull();
result.Model.Should().BeOfType<TestViewModel>();
(result.Model as TestViewModel)!.ContactTypes.Should().NotBeNull().And.HaveCount(count);
}
MemberData
Supply parameters from a static method or property — most flexible and commonly used:
public static IEnumerable<object[]> GetTestCounts()
{
yield return new object[] { 1 };
yield return new object[] { 5 };
yield return new object[] { 10 };
}
[Theory]
[MemberData(nameof(GetTestCounts))]
public async Task TestAction_ReturnsCorrectCount(int count)
{
await SeedDataAsync(count);
var result = await _homeController.Test() as ViewResult;
(result!.Model as TestViewModel)!
.ContactTypes.Should().HaveCount(count);
}
ClassData
Supply parameters via a generator class:
public class TestDataGenerator : IEnumerable<object[]>
{
private readonly List<object[]> _data = new()
{
new object[] {5},
new object[] {7},
new object[] {9},
};
public IEnumerator<object[]> GetEnumerator() => _data.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
[Theory]
[ClassData(typeof(TestDataGenerator))]
public async Task TestAction_ReturnsVm_WithDataCounts_Fluent(int count)
{
await SeedDataAsync(count);
// ACT
var result = await _homeController.Test() as ViewResult;
result.Should().NotBeNull();
result!.Model.Should().NotBeNull();
result.Model.Should().BeOfType<TestViewModel>();
(result.Model as TestViewModel)!.ContactTypes.Should().NotBeNull().And.HaveCount(count);
}
Test class setup and dependencies
Constructor-based setup
- Test class setup is done in constructor (Arrange phase)
- xUnit creates a new instance per test method, so each test gets a fresh setup
public class UnitTestHomeController : IDisposable
{
private readonly ITestOutputHelper _testOutputHelper;
private readonly HomeController _homeController;
private readonly AppDbContext _ctx;
private readonly SqliteConnection _connection;
public UnitTestHomeController(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
// set up SQLite in-memory database
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
optionsBuilder.UseSqlite(_connection);
_ctx = new AppDbContext(optionsBuilder.Options);
_ctx.Database.EnsureCreated();
// set up logger - it is not mocked, we are not testing logging functionality
using var logFactory = LoggerFactory.Create(builder => builder.AddConsole());
var logger = logFactory.CreateLogger<HomeController>();
//set up SUT
_homeController = new HomeController(logger, _ctx);
}
public void Dispose()
{
_ctx.Dispose();
_connection.Dispose();
}
// ... test methods
}
Test output
xUnit has special console like functionality for output — ITestOutputHelper
_testOutputHelper.WriteLine(result?.ToString());
IClassFixture — shared context
When multiple test classes need the same expensive setup (e.g., a WebApplicationFactory), use IClassFixture<T>:
- The fixture is created once and shared across all tests in the class
- Use this for integration tests where creating the test server is expensive
- The fixture instance is injected via the test class constructor
// The factory is created once and reused for all tests in this class
public class IntegrationTestHomeController : IClassFixture<CustomWebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public IntegrationTestHomeController(CustomWebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
// ... test methods share the same factory instance
}
IAsyncLifetime — async setup/teardown
When you need async initialization or cleanup (e.g., seeding a database), implement IAsyncLifetime:
public class DatabaseTests : IAsyncLifetime, IDisposable
{
private readonly AppDbContext _ctx;
private readonly SqliteConnection _connection;
public DatabaseTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
optionsBuilder.UseSqlite(_connection);
_ctx = new AppDbContext(optionsBuilder.Options);
}
public async Task InitializeAsync()
{
// async Arrange — runs before each test
await _ctx.Database.EnsureCreatedAsync();
await SeedDataAsync();
}
public async Task DisposeAsync()
{
// async cleanup — runs after each test
await _ctx.Database.EnsureDeletedAsync();
}
public void Dispose()
{
_ctx.Dispose();
_connection.Dispose();
}
// ... test methods
}
Mocking and stubbing
- Mocking is primarily used in unit testing. An object under test may have dependencies on other (complex) objects. To isolate the behavior of the object you want to replace the other objects by mocks that simulate the behavior of the real objects. This is useful if the real objects are impractical to incorporate into the unit test.
- In short, mocking is creating objects that simulate the behavior of real objects.
- Stub is a "minimal" simulated object. The stub implements just enough behavior to allow the object under test to execute the test.
- A mock is like a stub but the test will also verify that the object under test calls the mock as expected. Part of the test is verifying that the mock was used correctly.
- Moq — widely used, Nuget: Moq
- NSubstitute — popular alternative with cleaner syntax, Nuget: NSubstitute
- Best practice: code against interfaces (not virtual methods on concrete classes) — this makes mocking straightforward and aligns with Dependency Injection principles (see lecture 22)
Mocking with Moq
Object to mock — prefer defining an interface:
public interface IApiAdapter
{
void SaveStuff(string a);
string GetString(int a);
}
public class ApiAdapter : IApiAdapter
{
public void SaveStuff(string a)
{
// Expensive operation, paid api call
}
public string GetString(int a)
{
// Expensive operation, paid api call
return a.ToString();
}
}
SUT
public IActionResult TestApiCall()
{
string a = _adapter.GetString(1);
_adapter.SaveStuff(a);
string b = _adapter.GetString(2);
return Ok(b);
}
Mocking framework allows partial replacement of mocked object
private readonly Mock<IApiAdapter> _adapterMock;
public UnitTestHomeController(ITestOutputHelper testOutputHelper)
{
_adapterMock = new Mock<IApiAdapter>();
_adapterMock.Setup(x => x.GetString(It.Is<int>(a => a == 1))).Returns("3");
_adapterMock.Setup(x => x.GetString(It.Is<int>(a => a == 2))).Returns("2");
_adapterMock.Setup(x => x.SaveStuff(It.IsAny<string>())).Verifiable();
// ...
}
Test
[Fact]
public void TestMockableDependency()
{
var result = _homeController.TestApiCall();
_adapterMock.Verify(x => x.SaveStuff(It.Is<string>(a => a == "3")), Times.Once);
_adapterMock.Verify(x => x.SaveStuff(It.Is<string>(a => a == "2")), Times.Never);
result.Should().BeOfType<OkObjectResult>();
Assert.Equal("2", (result as OkObjectResult)!.Value as string);
}
Testing with SQLite in-memory database
Dependencies
- Unit testing should test code in isolation — independent of database, logging, etc.
- So we need to create mocked (fake) dependencies, with fixed/controllable behaviour
SQLite in-memory for DbContext
- SQLite in-memory mode provides a lightweight, real SQL database for testing
- Unlike the EF Core InMemory provider, SQLite enforces foreign keys, supports SQL queries, and behaves like a real relational database
- The connection must be kept open for the lifetime of the test — when the connection closes, the database is destroyed
- Nuget: Microsoft.EntityFrameworkCore.Sqlite
// set up SQLite in-memory database
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open(); // connection must stay open for db to persist
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
optionsBuilder.UseSqlite(connection);
_ctx = new AppDbContext(optionsBuilder.Options);
// create schema
_ctx.Database.EnsureCreated();
Mocking ILogger
- We are not testing/asserting logging functionality of our controllers, so this is not really mocked
using var logFactory = LoggerFactory.Create(builder => builder.AddConsole());
var logger = logFactory.CreateLogger<HomeController>();
Why SQLite over InMemory provider
| SQLite in-memory | EF Core InMemory provider | |
|---|---|---|
| Foreign keys | Enforced | Not enforced |
| SQL behavior | Real SQL (LINQ-to-SQL) | LINQ-to-Objects |
| Transactions | Supported | Not supported |
| Constraints | Unique indexes, checks enforced | Not enforced |
| Speed | Fast | Fast |
For even more realistic testing (e.g., PostgreSQL-specific features), consider Testcontainers — spin up a real database in Docker for each test run.
Testing controller with DbContext
-
Simple controller method
-
Dependencies
- ILogger
- AppDbContext
public class HomeController : Controller
{
private readonly ILogger<HomeController> _logger;
private readonly AppDbContext _context;
public HomeController(ILogger<HomeController> logger, AppDbContext context)
{
_logger = logger;
_context = context;
}
public async Task<IActionResult> Test()
{
var vm = new TestViewModel
{
ContactTypes = await _context.ContactTypes.ToListAsync()
};
return View(vm);
}
}
Integration tests
-
Broader tests are used to test the app's infrastructure and whole framework, often including the following components:
- Database
- File system
- Network appliances
- Request-response pipeline
-
Unit tests use fabricated components, known as fakes or mock objects, in place of infrastructure components.
-
In contrast to unit tests, integration tests:
- Use the actual components that the app uses in production.
- Require more code and data processing.
- Take longer to run.
ASP.NET integration tests
Integration tests follow a sequence of events that include the usual Arrange, Act, and Assert test steps:
- The SUT's web host is configured.
- A test server client is created to submit requests to the app.
- The Arrange test step is executed: The test app prepares a request.
- The Act test step is executed: The client submits the request and receives the response.
- The Assert test step is executed: The actual response is validated as a pass or fail based on an expected response.
Web Host
- Nuget: Microsoft.AspNetCore.Mvc.Testing
- Create custom factory to modify app startup — replace services in DI as needed
- Find DbContext, replace
- Seed data as needed
Add at the end of Program.cs — needed for integration testing to get correct access levels
public partial class Program
{
}
CustomWebApplicationFactory
Factory to create correct webhost, with modified DI setup
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace WebApp.Tests;
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// find DbContext
var descriptor = services.SingleOrDefault(
d => d.ServiceType ==
typeof(DbContextOptions<AppDbContext>));
// if found - remove
if (descriptor != null)
{
services.Remove(descriptor);
}
// add new DbContext with SQLite in-memory database
services.AddDbContext<AppDbContext>(options =>
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
options.UseSqlite(connection);
});
// create db and seed data
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var scopedServices = scope.ServiceProvider;
var db = scopedServices.GetRequiredService<AppDbContext>();
var logger = scopedServices
.GetRequiredService<ILogger<CustomWebApplicationFactory>>();
db.Database.EnsureCreated();
try
{
DataSeeder.SeedData(db);
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred seeding the " +
"database with test messages. Error: {Message}", ex.Message);
}
});
}
}
Integration test class
- Test the response from httpClient
namespace WebApp.Tests.Controllers;
public class IntegrationTestHomeController : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
private readonly CustomWebApplicationFactory _factory;
public IntegrationTestHomeController(CustomWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
}
[Fact]
public async Task Get_Index()
{
// Arrange
// Act
var response = await _client.GetAsync("/");
// Assert
response.EnsureSuccessStatusCode();
}
}
Testing HTTP responses
- DOM parser for analyzing response html
- Browserlike context for producing next request (cookies, etc)
- Nuget: AngleSharp.Io
Create response context Create browser like dom tree and context in C#
using System.Net.Http.Headers;
using AngleSharp;
using AngleSharp.Html.Dom;
using AngleSharp.Io;
namespace WebApp.Tests.Helpers;
public static class HtmlHelpers
{
public static async Task<IHtmlDocument> GetDocumentAsync(HttpResponseMessage response)
{
var content = await response.Content.ReadAsStringAsync();
var document = await BrowsingContext.New()
.OpenAsync(ResponseFactory, CancellationToken.None);
return (IHtmlDocument)document;
void ResponseFactory(VirtualResponse htmlResponse)
{
htmlResponse
.Address(response.RequestMessage?.RequestUri)
.Status(response.StatusCode);
MapHeaders(response.Headers);
MapHeaders(response.Content.Headers);
htmlResponse.Content(content);
void MapHeaders(HttpHeaders headers)
{
foreach (var header in headers)
{
foreach (var value in header.Value)
{
htmlResponse.Header(header.Key, value);
}
}
}
}
}
}
Integration test — parsing response
Parse actual response, assert expected content
[Fact]
public async Task TestAction_HasCorrectData()
{
// ARRANGE
const string uri = "/Home/Test";
// ACT
var getTestResponse = await _client.GetAsync(uri);
getTestResponse.EnsureSuccessStatusCode();
// get the actual content from response
var getTestContent = await HtmlHelpers.GetDocumentAsync(getTestResponse);
var testTable = (IHtmlTableElement) getTestContent.QuerySelector("#table");
// ASSERT
Assert.NotNull(testTable);
var testTableSection = testTable.Bodies.FirstOrDefault();
Assert.NotNull(testTableSection);
Assert.Equal(1, testTableSection!.Rows.Length);
}
Testing get->submit->redirect->get cycle
private async Task RegisterUserAsync()
{
// ARRANGE
const string registerUri = "/Identity/Account/Register";
// ACT
var getRegisterResponse = await _client.GetAsync(registerUri);
// ASSERT
getRegisterResponse.EnsureSuccessStatusCode();
// ARRANGE get the actual content from response
var getRegisterContent = await HtmlHelpers.GetDocumentAsync(getRegisterResponse);
// get the form element from page content
var formRegister = (IHtmlFormElement) getRegisterContent.QuerySelector("#form-register");
// set up the form values - username, pwd, etc
var formRegisterValues = new Dictionary<string, string>
{
["Input_Email"] = "test@user.ee",
["Input_Password"] = "Foo.bar1",
["Input_ConfirmPassword"] = "Foo.bar1",
};
// ACT send form with data to server, method (POST) is detected from form element
var postRegisterResponse = await _client.SendAsync(formRegister, formRegisterValues);
// ASSERT found - 302 - ie user was created and we should redirect
Assert.Equal(HttpStatusCode.Found, postRegisterResponse.StatusCode);
}
Http Client Extensions
Helper methods for submitting forms in integration tests
using AngleSharp.Html.Dom;
using Xunit;
namespace WebApp.Tests.Helpers;
public static class HttpClientExtensions
{
public static Task<HttpResponseMessage> SendAsync(
this HttpClient client,
IHtmlFormElement form,
IHtmlElement submitButton)
{
return client.SendAsync(form, submitButton, new Dictionary<string, string>());
}
public static Task<HttpResponseMessage> SendAsync(
this HttpClient client,
IHtmlFormElement form,
IEnumerable<KeyValuePair<string, string>> formValues)
{
var submitElement = Assert.Single(form.QuerySelectorAll("[type=submit]"));
var submitButton = Assert.IsAssignableFrom<IHtmlElement>(submitElement);
return client.SendAsync(form, submitButton, formValues);
}
public static Task<HttpResponseMessage> SendAsync(
this HttpClient client,
IHtmlFormElement form,
IHtmlElement submitButton,
IEnumerable<KeyValuePair<string, string>> formValues)
{
foreach (var (key, value) in formValues)
{
switch (form[key])
{
case IHtmlInputElement:
{
(form[key] as IHtmlInputElement)!.Value = value;
if ((form[key] as IHtmlInputElement)!.Type == "checkbox" && bool.Parse(value))
{
(form[key] as IHtmlInputElement)!.IsChecked = true;
}
break;
}
case IHtmlSelectElement:
{
(form[key] as IHtmlSelectElement)!.Value = value;
break;
}
}
}
var submit = form.GetSubmission(submitButton);
var target = (Uri) submit!.Target;
if (submitButton.HasAttribute("formaction"))
{
var formaction = submitButton.GetAttribute("formaction");
if (!string.IsNullOrEmpty(formaction))
target = new Uri(formaction, UriKind.Relative);
}
var submission = new HttpRequestMessage(new HttpMethod(submit.Method.ToString()), target)
{
Content = new StreamContent(submit.Body)
};
foreach (var (key, value) in submit.Headers)
{
submission.Headers.TryAddWithoutValidation(key, value);
submission.Content.Headers.TryAddWithoutValidation(key, value);
}
return client.SendAsync(submission);
}
}
REST API endpoint testing
When your application exposes a REST API, test the JSON endpoints directly using HttpClient:
[Fact]
public async Task GetAll_ReturnsSuccessAndCorrectContentType()
{
// ACT
var response = await _client.GetAsync("/api/v1/ContactTypes");
// ASSERT
response.EnsureSuccessStatusCode();
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
}
[Fact]
public async Task GetAll_ReturnsListOfContactTypes()
{
// ACT
var result = await _client.GetFromJsonAsync<List<ContactTypeDto>>("/api/v1/ContactTypes");
// ASSERT
Assert.NotNull(result);
result.Should().HaveCountGreaterThan(0);
}
[Fact]
public async Task Create_ReturnsCreatedAndEntity()
{
// ARRANGE
var newItem = new ContactTypeCreateDto { Name = "Email", Description = "Email contact" };
// ACT
var response = await _client.PostAsJsonAsync("/api/v1/ContactTypes", newItem);
// ASSERT
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var created = await response.Content.ReadFromJsonAsync<ContactTypeDto>();
Assert.NotNull(created);
Assert.Equal("Email", created!.Name);
}
Testing with authentication
Most endpoints require authentication. You need to handle this in integration tests.
Creating test users in WebApplicationFactory
Seed a test user during factory setup:
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// ... existing DbContext replacement ...
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<AppUser>>();
var user = new AppUser { UserName = "test@test.ee", Email = "test@test.ee" };
userManager.CreateAsync(user, "Test.pass1").GetAwaiter().GetResult();
});
}
Testing JWT-authenticated API endpoints
private async Task<string> GetJwtTokenAsync()
{
var loginDto = new LoginDto { Email = "test@test.ee", Password = "Test.pass1" };
var response = await _client.PostAsJsonAsync("/api/v1/identity/account/login", loginDto);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JwtResponseDto>();
return result!.Token;
}
[Fact]
public async Task GetAll_Authenticated_ReturnsData()
{
// ARRANGE
var token = await GetJwtTokenAsync();
_client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
// ACT
var response = await _client.GetAsync("/api/v1/ContactTypes");
// ASSERT
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task GetAll_WithoutAuth_Returns401()
{
// ACT - no auth header set
var response = await _client.GetAsync("/api/v1/ContactTypes");
// ASSERT
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
E2E testing with Playwright
End-to-end tests drive the application through the browser, just like a real user. They sit at the top of the testing pyramid — use them sparingly for critical user journeys.
Why Playwright
- Playwright for .NET — modern browser automation library from Microsoft
- Supports Chromium, Firefox, WebKit
- Auto-wait for elements, built-in assertions
- Much faster and more reliable than Selenium
- Nuget: Microsoft.Playwright
Basic example — login and form submission
using Microsoft.Playwright;
[Fact]
public async Task User_CanRegisterAndLogin()
{
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync();
var page = await browser.NewPageAsync();
// Navigate to register page
await page.GotoAsync("https://localhost:5001/Identity/Account/Register");
// Fill out registration form
await page.FillAsync("#Input_Email", "e2e@test.ee");
await page.FillAsync("#Input_Password", "Test.pass1");
await page.FillAsync("#Input_ConfirmPassword", "Test.pass1");
await page.ClickAsync("button[type=submit]");
// Assert — should redirect to home page after registration
await Expect(page).ToHaveURLAsync("https://localhost:5001/");
// Navigate to login
await page.GotoAsync("https://localhost:5001/Identity/Account/Login");
await page.FillAsync("#Input_Email", "e2e@test.ee");
await page.FillAsync("#Input_Password", "Test.pass1");
await page.ClickAsync("button[type=submit]");
// Assert — user is logged in, greeting visible
await Expect(page.Locator("text=e2e@test.ee")).ToBeVisibleAsync();
}
When to use E2E tests
- Critical user flows (registration, login, checkout)
- Smoke tests — does the app start and serve pages at all?
- Do not test every CRUD operation via E2E — use integration tests for that
- E2E tests are slow and brittle — keep the count low
Test Driven Development
- Software development approach in which test cases are developed to specify and validate what the code will do. Test cases for each functionality are created and tested first and if the test fails then the new code is written in order to pass the test and making code simple and bug-free.
- Philosophical problems — TDD is almost impossible while you are learning new language/framework. But the discipline of writing tests alongside your code is essential regardless of order.

TDD benefits
- Early bug notification. Developers test their code but in the database world, this often consists of manual tests or one-off scripts. Using TDD you build up, over time, a suite of automated tests that you and any other developer can rerun at will.
- Better Designed, cleaner and more extensible code. It helps to understand how the code will be used and how it interacts with other modules. It results in better design decision and more maintainable code. TDD allows writing smaller code having single responsibility rather than monolithic procedures with multiple responsibilities. This makes the code simpler to understand. TDD also forces to write only production code to pass tests based on user requirements.
- Confidence to Refactor If you refactor code, there can be possibilities of breaking the code. So having a set of automated tests you can fix those problems before release. Proper warning will be given if errors are found when automated tests are used. Using TDD should result in faster, more extensible code with fewer bugs that can be updated with minimal risks.
- Good for teamwork In the absence of any team member, other team members can easily pick up and work on the code. It also aids knowledge sharing, thereby making the team more effective overall.
- Good for Developers Though developers have to spend more time in writing TDD test cases, it takes a lot less time for debugging and developing new features. You will write cleaner, less complicated code.
- Tests as specification TDD tests will form the specification of what the code must do.
Code coverage
- Code coverage measures how much of your source code is executed during tests
- The
coverlet.collectorpackage in your test project enables coverage collection
# collect coverage during test run
dotnet test --collect:"XPlat Code Coverage"
# output is in TestResults/ folder as coverage.cobertura.xml
# use ReportGenerator tool to create HTML report
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:TestResults/**/coverage.cobertura.xml -targetdir:coveragereport
- Do not aim for 100% — focus on meaningful coverage of business logic
- 70-80% coverage on business logic is a reasonable target
- Coverage tells you what is NOT tested — it does not tell you if your tests are good
Testing in Clean Architecture
In a Clean Architecture solution, each layer has different testing strategies:
Domain layer — pure unit tests
- Test entities, value objects, domain services
- No mocking needed — domain has no external dependencies
- Test business rules, validations, calculations
[Fact]
public void Order_AddItem_CalculatesTotalCorrectly()
{
var order = new Order();
order.AddItem(new OrderItem { ProductName = "Widget", Price = 9.99m, Quantity = 3 });
Assert.Equal(29.97m, order.Total);
}
Application layer — use case tests
- Test use cases / command handlers with mocked repositories
- Verify correct repository calls and business logic orchestration
[Fact]
public async Task CreateContactType_ValidInput_CallsRepositoryAndReturns()
{
var mockRepo = new Mock<IContactTypeRepository>();
mockRepo.Setup(r => r.AddAsync(It.IsAny<ContactType>()))
.ReturnsAsync(new ContactType { Id = Guid.NewGuid(), Name = "Email" });
var handler = new CreateContactTypeHandler(mockRepo.Object);
var result = await handler.HandleAsync(new CreateContactTypeCommand { Name = "Email" });
Assert.NotNull(result);
mockRepo.Verify(r => r.AddAsync(It.IsAny<ContactType>()), Times.Once);
}
Infrastructure layer — repository tests
- Test against a real (or realistic) database
- Verify that queries, includes, filtering work correctly
Presentation layer — integration tests
- Use
WebApplicationFactoryto test API endpoints and MVC controllers - The integration tests shown earlier in this lecture cover this layer
Test project organization
Solution/
├── src/
│ ├── Domain/
│ ├── Application/
│ ├── Infrastructure/
│ └── WebApp/
└── tests/
├── Domain.Tests/ ← pure unit tests
├── Application.Tests/ ← use case tests with mocks
└── WebApp.Tests/ ← integration + E2E tests
Defense tips and AI-generated tests
What you should be able to explain in defense
- What does each test verify and why is that important?
- Why did you choose unit tests vs integration tests for a given scenario?
- What is being mocked and why?
- What would happen if this test didn't exist?
AI-generated tests — watch out for
AI tools can generate tests quickly, but common pitfalls include:
- Tests that assert nothing meaningful — e.g.,
Assert.NotNull(result)without checking the actual content - Tests that test the framework, not your code — e.g., verifying that EF Core can save and retrieve, rather than testing your business logic
- Over-mocking — mocking so many things that the test verifies nothing about real behavior
- Copy-paste tests — identical test bodies with different names that don't actually cover different scenarios
You must understand every test in your project. If you cannot explain what a test does and why it matters during defense, it works against you rather than for you.
Self preparation QA
Be prepared to explain topics like these:
- Why follow the testing pyramid (many unit tests, fewer integration tests, few E2E tests)? — Unit tests are fast and isolated. Integration tests are slower and more complex. E2E tests are the slowest and most brittle. Investing inversely makes the test suite slow and fragile.
- What is the difference between unit tests and integration tests? — Unit tests exercise individual components in isolation, mocking all dependencies. Integration tests exercise multiple components together to verify real interactions.
- Why use
WebApplicationFactory<T>for integration testing? — It creates an in-memory test server with the full ASP.NET Core pipeline. You can override DI registrations and verify end-to-end behavior including serialization and authentication. - Why use FluentAssertions instead of raw Assert calls? — FluentAssertions provides readable, chainable assertions with descriptive failure messages.
- What is the Arrange-Act-Assert (AAA) pattern? — Arrange: set up data and dependencies. Act: execute the operation. Assert: verify the result. Each test should have one clear Act step.
- Why use Moq for unit testing and what does it replace? — Moq creates mock implementations of interfaces at runtime. It isolates the unit under test from external dependencies.
- How do you test API endpoints that require authentication? — Call the login endpoint first to get a JWT, then set the
Authorization: Bearerheader on subsequent requests. Alternatively, configure a test authentication handler.