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));
}