Unit Testing and Integration Testing
A unit test checks one small behaviour in isolation, runs in milliseconds, and gives the same result every time.
Concept
Unit Testing Principles A unit test checks one small behaviour in isolation, runs in milliseconds, and gives the same result every time.
Arrange–Act–Assert (AAA): set up, execute the behaviour, verify the outcome.
Isolation: no shared state between tests, no real network, disk, clock or hardware — replace them with test doubles.
Naming: Method_Scenario_ExpectedResult (e.g. Clear_IsRefused_DuringCalibrationWindow).
One reason to fail per test; test behaviour, not implementation details.
FIRST: Fast, Isolated, Repeatable, Self-validating, Timely.
Passed but never used Returns canned answers A working lightweight implementation (in-memory repository, fake clock) Records how it was called
Pre-programmed with expectations that are verified (Moq, NSubstitute, FakeItEasy generate them)
[Theory] + [InlineData]/[MemberData] Constructor / Dispose
This course uses xUnit v3, which runs on the Microsoft Testing Platform (MTP). With the .NET 10 SDK, dotnet test uses MTP when global.json contains "test": { "runner": "Microsoft.Testing.Platform" }.
Integration tests check several components together with real infrastructure:
HTTP APIs: start the real ASP.NET Core pipeline — on a loopback port (as below) or in memory with WebApplicationFactory<T> (Microsoft.AspNetCore.Mvc.Testing) — and call it with HttpClient.
Databases: a real engine per test run: SQLite in memory, or real SQL Server/PostgreSQL in Docker via Testcontainers.
Test data and cleanup: each test creates what it needs and leaves nothing behind (fresh database, transaction rolled back, unique names).
Environments: a Testing environment with its own configuration.
The test pyramid: many unit tests, fewer integration tests, fewer contract tests, very few end-to-end tests (HIL bench tests are at the top — slow and expensive).
Contract tests (e.g. Pact, or schema/OpenAPI checks) verify that a provider still satisfies what its consumers expect, without deploying both.
Flaky tests (pass/fail without code changes) come from time, ordering, shared state, real networks or races — fix or quarantine them immediately; they destroy trust in CI.
Code coverage (dotnet test --coverage with MTP, or coverlet) shows *untested* code; high coverage doesn't prove good tests.
Mutation testing (Stryker.NET) changes the code (> → >=) and checks that some test fails — it measures test *quality*.
flowchart TB
E2E["End-to-end tests: few, slow (HIL bench, full UI)"] --> CT["Contract tests: API/message schemas between services"]
CT --> IT["Integration tests: real DB, real HTTP pipeline"]
IT --> UT["Unit tests: many, fast, isolated"]Why It Matters
Tests are the safety net that lets you refactor, upgrade frameworks and change ECU logic without fear, and CI pipelines (Module 59) run them on every commit. Designing for testability (injected clocks and connections) is also good design.
Syntax
public class ScalingTests
{
[Fact] public void Rejects_OutOfRange() => Assert.Throws<ArgumentOutOfRangeException>(() => Scaling.AdcToVolts(5000));
[Theory, InlineData(0, 0.0), InlineData(4095, 5.0)]
public void Scales(int adc, double v) => Assert.Equal(v, Scaling.AdcToVolts(adc), precision: 4);
}Example
47.1 A library under test Diag.Core contains a service with injected dependencies (connection and clock) and a scaling function with a bug:
47.2 The test project Unit tests with hand-written test doubles, [Fact] and [Theory]:
Integration tests: a real web API on a loopback port shared through a class fixture, and a real SQLite database per test:
47.3 Fixing the bug
namespace Diag.Core;
public record Dtc(string Code, bool Active);
public interface IEcuConnection
{
IReadOnlyList<Dtc> ReadDtcs(string ecu);
void Send(string ecu, string request);
}
public interface IClock { DateTime UtcNow { get; } }
public class DtcService(IEcuConnection connection, IClock clock)
{
/// <summary>Returns active DTC codes, sorted.</summary>
public IReadOnlyList<string> ActiveCodes(string ecu) =>
connection.ReadDtcs(ecu).Where(d => d.Active).Select(d => d.Code).Order().ToList();
/// <summary>Clears DTCs, but never during the first 5 minutes after midnight UTC (nightly calibration window).</summary>
public bool Clear(string ecu)
{
if (clock.UtcNow.TimeOfDay < TimeSpan.FromMinutes(5)) return false;
connection.Send(ecu, "14 FF FF FF");
return true;
}
public static bool IsValidCode(string code) =>
code.Length == 5 && "PCBU".Contains(code[0]) && code[1..].All(Uri.IsHexDigit);
}namespace Diag.Core;
public static class Scaling
{
/// <summary>Converts a 0..4095 ADC reading to volts (0..5 V).</summary>
public static double AdcToVolts(int adc)
{
if (adc < 0 || adc > 4095) throw new ArgumentOutOfRangeException(nameof(adc), adc, "ADC range is 0..4095");
return adc * 5 / 4095; // BUG: integer division
}
}<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="4.0.1" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.12" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\Diag.Core\Diag.Core.csproj" />
<Using Include="Xunit" />
</ItemGroup>
</Project>{
"test": { "runner": "Microsoft.Testing.Platform" }
}using Diag.Core;
namespace Diag.Tests;
// ---- Test doubles written by hand (Moq/NSubstitute generate the same thing dynamically) ----
class StubConnection(params Dtc[] dtcs) : IEcuConnection // stub: returns canned data
{
public List<string> Sent { get; } = []; // ...and records calls (a spy)
public IReadOnlyList<Dtc> ReadDtcs(string ecu) => dtcs;
public void Send(string ecu, string request) => Sent.Add($"{ecu}:{request}");
}
class FakeClock(DateTime now) : IClock { public DateTime UtcNow => now; } // fake: a working, simplified implementation
public class DtcServiceTests
{
[Fact]
public void ActiveCodes_ReturnsOnlyActiveCodes_Sorted()
{
// Arrange
var connection = new StubConnection(new("U0100", true), new("P0A80", true), new("C1234", false));
var service = new DtcService(connection, new FakeClock(new DateTime(2026, 9, 30, 12, 0, 0)));
// Act
IReadOnlyList<string> codes = service.ActiveCodes("VCU");
// Assert
Assert.Equal(["P0A80", "U0100"], codes);
}
[Fact]
public void Clear_SendsClearRequest_OutsideCalibrationWindow()
{
var connectio
…using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
namespace Diag.Tests;
// A fixture is created once and shared by all tests in the class (IClassFixture), then disposed.
public sealed class ApiFixture : IAsyncLifetime
{
public WebApplication App { get; private set; } = null!;
public HttpClient Client { get; private set; } = null!;
public async ValueTask InitializeAsync()
{
var builder = WebApplication.CreateSlimBuilder();
builder.Logging.ClearProviders();
builder.WebHost.UseUrls("http://127.0.0.1:0"); // real Kestrel on a free port
App = builder.Build();
App.MapGet("/health", () => Results.Ok(new { status = "ok" }));
App.MapGet("/ecus/{code}", (string code) => code == "VCU" ? Results.Ok(new { code, fw = "2.4.1" }) : Results.NotFound());
await App.StartAsync();
Client = new HttpClient { BaseAddress = new Uri(App.Urls.First()) };
}
public async ValueTask DisposeAsync()
{
Client.Dispose();
await A
…namespace Diag.Core;
public static class Scaling
{
/// <summary>Converts a 0..4095 ADC reading to volts (0..5 V).</summary>
public static double AdcToVolts(int adc)
{
if (adc < 0 || adc > 4095) throw new ArgumentOutOfRangeException(nameof(adc), adc, "ADC range is 0..4095");
return adc * 5.0 / 4095; // fixed: floating-point division
}
}Expected Result
The first dotnet test runs against the buggy Scaling, the second after the fix:
Only the 2048 case failed: 0 and 4095 happen to give exact integers, which is precisely why tests need values *between* the boundaries. Durations will differ on your machine.
> dotnet test --project Diag.Tests
Running tests from C:\demo\Diag.Tests\bin\Debug\net10.0\Diag.Tests.dll (net10.0|x64)
failed Diag.Tests.ScalingTests.AdcToVolts_ScalesLinearly(adc: 2048, volts: 2.5005999999999999) (3ms)
from C:\demo\Diag.Tests\bin\Debug\net10.0\Diag.Tests.dll (net10.0|x64)
Assert.Equal() Failure: Values are not within 4 decimal places
Expected: 2.5005999999999999 (rounded from 2.5005999999999999)
Actual: 2 (rounded from 2)
at Diag.Tests.ScalingTests.AdcToVolts_ScalesLinearly(Int32 adc, Double volts) in C:\demo\Diag.Tests\DtcServiceTests.cs:71
at InvokeStub_ScalingTests.AdcToVolts_ScalesLinearly(Object, Span`1)
at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
C:\demo\Diag.Tests\bin\Debug\net10.0\Diag.Tests.dll (net10.0|x64) failed with 1 error(s) (992ms)
Exit code: 2
Test run summary: Failed!
total: 16
failed: 1
succeeded: 15
skipped: 0
duration: 2s 097ms
Test run completed with non-success exit code: 2 (see: https://aka.ms/testingplatform/exitcodes)
> dotnet test --project Diag.Tests
Running tests from C:\demo\Diag.Tests\bin\Debug\
…Common Mistakes
Tests that depend on DateTime.Now, the real file system, the network or test execution order.
Testing private methods instead of observable behaviour.
Asserting too much (brittle tests that break on every refactor) or too little (no assertion at all).
Mocking everything, including simple value objects.
Comparing floating-point values with exact equality — use a precision/tolerance.
Integration tests sharing one database without cleanup, making them order-dependent.
Best Practices
Inject time (TimeProvider in .NET 8+ or an IClock) and I/O so logic is unit-testable.
Use [Theory] for boundary values: min, max, just inside, just outside, typical.
Keep tests fast; run them on every build in CI; measure coverage but don't chase 100%.
Write a failing test that reproduces a bug before fixing it (as above).
Use Testcontainers for integration tests against the same database engine as production.
Interview Relevance
Very high: "what is AAA?", "stub vs mock vs fake?", "unit vs integration tests?", "how do you test code that uses DateTime.Now?", "how do you test an ASP.NET Core API?", "what makes a good unit test?".
Practice Questions
Add a [Theory] with [MemberData] that feeds 10 random-but-seeded ADC values.
Replace the hand-written stub with Moq: mock.Setup(c => c.ReadDtcs("VCU")).Returns(...) and mock.Verify(...).
Rewrite FakeClock using TimeProvider and Microsoft.Extensions.TimeProvider.Testing's FakeTimeProvider.
Write an integration test for a POST endpoint that checks the 201 status and the Location header.
Quick Quiz
Q1. (Multiple choice) A class that records the requests it receives so the test can inspect them is a: A) Dummy B) Stub C) Spy D) Fake Answer: C.
Q2. (Predict the output) return adc * 5 / 4095; for adc = 2048 (the method returns double).
Answer: 2 — integer division happens before the conversion to double (verified by the failing test: *Actual: 2*).
Q3. (True/False) In xUnit, a new instance of the test class is created for every test.
Answer: True. That is why the constructor/Dispose pair works as per-test setup/cleanup (the SQLite database above is fresh for each test).
Q4. (Find the bug) [Fact] public void Clear_Works() { var s = new DtcService(conn, new SystemClock()); Assert.True(s.Clear("VCU")); }
Answer: The result depends on the real time of day — the test fails between 00:00 and 00:05 UTC. Inject a fake clock.
Coding Challenge
Write xUnit tests for a static bool IsValidVin(string vin) (17 characters, no I/O/Q): one [Theory] with valid and invalid cases, including the boundary lengths 16 and 18.
// not a complete program: goes into a test project like Diag.Tests above
public static class Vin
{
public static bool IsValid(string vin) =>
vin.Length == 17 && vin.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c)) && !vin.Any(c => c is 'I' or 'O' or 'Q');
}
public class VinTests
{
[Theory]
[InlineData("EHBAUREV250000001", true)]
[InlineData("EHBAUREV25000000", false)] // 16 characters
[InlineData("EHBAUREV2500000011", false)] // 18 characters
[InlineData("EHBAUREV25000000I", false)] // I is not allowed
[InlineData("ehbaurev250000001", false)] // lower case
public void IsValid_ChecksLengthAndAlphabet(string vin, bool expected) =>
Assert.Equal(expected, Vin.IsValid(vin));
}