A travel booking team has a reassuring build pipeline. Every pull request runs hundreds of unit tests, and the test report is green. Yet a release reaches staging with a serious defect: customers can calculate a valid trip price, but the final booking page fails before confirmation.
The unit tests were not useless. They correctly proved that isolated pricing methods behaved as expected. The problem was that the test suite answered only one kind of question. It did not verify the ASP.NET Core request pipeline, the connection between components, or the browser workflow that customers actually use.
A reliable enterprise test strategy needs several layers. Fast tests should catch business-rule mistakes early, controlled application tests should verify that components work together, and a smaller number of browser tests should protect critical user journeys. The goal is not to automate every possible interaction. It is to place each risk at the lowest test layer that can detect it accurately.
The Failure Behind the Green Build
Suppose the booking flow contains these steps:
- A customer selects a destination and travel dates.
- The system calculates a price.
- The customer confirms the reservation.
- The application checks availability.
- The confirmation page displays the booking reference.
A unit test can validate the price calculation without starting the application. Another unit test can verify that an unavailable package is rejected. Both may pass while the deployed workflow still fails because of routing, dependency registration, middleware, authentication, configuration, or page behavior.
This is why the test suite should be organized by the type of risk being checked.
| Test layer | Main purpose | Example risk |
|---|---|---|
| Unit test | Verify one class or method in isolation | Incorrect discount or validation rule |
| Integration test | Verify cooperating components | Repository and service do not work together |
| Controlled functional test | Exercise a locally started application | Endpoint, middleware, or dependency setup is broken |
| Staging functional test | Verify the deployed system in a realistic environment | Configuration or external integration differs |
| Browser test | Simulate an important user journey | Form, navigation, or displayed result is wrong |
| Performance test | Check behavior under expected workload | Response time or capacity does not meet requirements |
A test should be moved higher only when a lower layer cannot observe the behavior that matters. Higher-level tests are usually more realistic, but they are also harder to prepare, slower to run, and more sensitive to environmental or user interface changes.
Start with Acceptance Behavior
Before writing implementation code, describe what the user expects from the booking workflow. This keeps the test design connected to business behavior rather than the current class structure.
Behavior-Driven Development, or BDD, is useful for acceptance scenarios because its Given, When, Then format can be read by developers and non-developers.
Feature: Confirm a travel booking
Scenario: Package is available
Given a customer selected an available package
And the calculated total is valid
When the customer confirms the booking
Then a booking reference is displayed
Scenario: Package becomes unavailable
Given a customer selected a package
And the package is no longer available
When the customer confirms the booking
Then the reservation is rejected
And no booking reference is created
These scenarios do not dictate which class, endpoint, or database table must exist. They define observable behavior.
That distinction matters. A test based only on existing implementation details can prove that the code does what it already does, while missing the fact that it does not satisfy the intended requirement.
Design the Unit Tests Before the Implementation
Test-Driven Development, or TDD, starts with expected behavior and representative execution paths. The basic cycle is:
- Write a test for behavior that does not yet exist.
- Run it and confirm that it fails for the expected reason.
- Add the smallest useful implementation.
- Run the tests again.
- Refactor while keeping the suite green.
Do not choose random test values and assume that quantity creates coverage. Select examples that represent different paths, especially boundaries and extreme cases.
For a booking total calculator, relevant cases may include:
- One traveler
- Several travelers
- Zero travelers
- A valid discount
- No discount
- A discount at its accepted boundary
- An invalid negative value
The following xUnit test uses [Theory] and [InlineData] because the same behavior must be checked with several inputs.
public sealed class BookingTotalCalculatorTests
{
[Theory]
[InlineData(1, 800, 0, 800)]
[InlineData(2, 800, 0, 1600)]
[InlineData(2, 800, 10, 1440)]
public void Calculate_returns_expected_total(
int travellers,
decimal pricePerTraveller,
decimal discountPercent,
decimal expected)
{
var calculator = new BookingTotalCalculator();
var result = calculator.Calculate(
travellers,
pricePerTraveller,
discountPercent);
Assert.Equal(expected, result);
}
[Fact]
public void Calculate_rejects_zero_travellers()
{
var calculator = new BookingTotalCalculator();
Assert.Throws<ArgumentOutOfRangeException>(
() => calculator.Calculate(0, 800, 0));
}
}
A possible implementation can then be written to satisfy the behavior.
public sealed class BookingTotalCalculator
{
public decimal Calculate(
int travellers,
decimal pricePerTraveller,
decimal discountPercent)
{
if (travellers <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(travellers));
}
var subtotal = travellers * pricePerTraveller;
var reduction = subtotal * discountPercent / 100;
return subtotal - reduction;
}
}
The first test checks several normal execution paths. The second protects an invalid boundary. Additional tests should be added when a new execution path or previously missed defect is discovered.
Isolate Dependencies with Moq
The booking confirmation service depends on package availability. A unit test should not contact a real remote service merely to verify confirmation logic. A mock provides controlled behavior for that dependency.
public interface IPackageAvailability
{
Task<bool> IsAvailableAsync(
string packageCode,
CancellationToken cancellationToken);
}
public sealed class BookingConfirmationService
{
private readonly IPackageAvailability _availability;
public BookingConfirmationService(
IPackageAvailability availability)
{
_availability = availability;
}
public async Task<string> ConfirmAsync(
string packageCode,
CancellationToken cancellationToken)
{
var isAvailable = await _availability.IsAvailableAsync(
packageCode,
cancellationToken);
if (!isAvailable)
{
throw new InvalidOperationException(
"The selected package is unavailable.");
}
return "BOOKING-CONFIRMED";
}
}
Moq can define the asynchronous result and verify that the dependency was used correctly.
public sealed class BookingConfirmationServiceTests
{
[Fact]
public async Task ConfirmAsync_returns_confirmation_when_available()
{
var availability = new Mock<IPackageAvailability>();
availability
.Setup(service => service.IsAvailableAsync(
"CITY-WEEKEND",
It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var sut = new BookingConfirmationService(
availability.Object);
var result = await sut.ConfirmAsync(
"CITY-WEEKEND",
CancellationToken.None);
Assert.Equal("BOOKING-CONFIRMED", result);
availability.Verify(
service => service.IsAvailableAsync(
"CITY-WEEKEND",
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task ConfirmAsync_rejects_unavailable_package()
{
var availability = new Mock<IPackageAvailability>();
availability
.Setup(service => service.IsAvailableAsync(
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var sut = new BookingConfirmationService(
availability.Object);
await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.ConfirmAsync(
"ISLAND-ESCAPE",
CancellationToken.None));
}
}
Mock only the boundaries needed to isolate the class under test. If every internal method is mocked and verified, the tests become coupled to implementation details and make safe refactoring harder.
Keep Test State Independent
xUnit creates a new test-class instance for each test. Constructor code therefore runs before every test, which is useful for fresh setup. A test class can implement IDisposable when cleanup is required after each test.
Some resources are expensive to create repeatedly. A shared fixture can prepare them once for a test class or a collection of test classes.
public sealed class BookingDataFixture : IDisposable
{
public BookingDataFixture()
{
DataStore = new TestBookingStore();
DataStore.Initialize();
}
public TestBookingStore DataStore { get; }
public void Dispose()
{
DataStore.Dispose();
}
}
public sealed class BookingRepositoryTests :
IClassFixture<BookingDataFixture>
{
private readonly BookingDataFixture _fixture;
public BookingRepositoryTests(
BookingDataFixture fixture)
{
_fixture = fixture;
}
[Fact]
public void Saved_booking_can_be_loaded()
{
var repository = new BookingRepository(
_fixture.DataStore);
repository.Save("REF-1001");
Assert.NotNull(repository.Find("REF-1001"));
}
}
Sharing setup improves speed, but shared mutable state can make tests influence one another. Reset mutable data between tests, or restrict shared fixtures to resources whose state can be controlled safely.
Test the ASP.NET Core Application, Not Only Its Classes
Calling a controller method directly is sometimes useful, but it bypasses the HTTP protocol and the ASP.NET Core pipeline. Such a test may miss routing, middleware, authentication, authorization, cross-origin rules, and service registration.
WebApplicationFactory<T> starts a controlled application for functional testing. The test receives an HttpClient that sends requests through the application.
public sealed class BookingEndpointTests :
IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public BookingEndpointTests(
WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Theory]
[InlineData("/booking")]
[InlineData("/booking/summary")]
public async Task Booking_pages_return_success(string url)
{
var client = _factory.CreateClient();
var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
}
}
When the application uses top-level statements, the entry point may need to be made public for the factory.
public partial class Program
{
}
Controlled application tests are reproducible because the application can start from a known state. They are suitable for testing boundary cases and failure paths that would be difficult or unsafe to create in a shared staging environment.
An in-memory database can simplify preparation, but it may not behave exactly like the production database. Keep tests against the real database technology for behavior where compatibility matters.
Use Staging for Realism, Not Exhaustive Edge Cases
A staging environment is closer to production. It can reveal differences in deployment configuration, external services, certificates, database behavior, and infrastructure.
It is also shared and harder to reset. Tests may fail because another test or user changed data. For that reason, do not rely on staging as the only place where edge cases are validated.
A practical division is:
- Run deterministic edge cases in a controlled application.
- Run a smaller set of representative workflows against staging.
- Avoid destructive scenarios on shared data.
- Use test records that can be identified and cleaned safely.
- Treat staging failures as possible environment or state problems until investigated.
This balance combines repeatability with realism.
Protect the Critical Browser Journey with Selenium
A successful HTTP response does not prove that a customer can complete the page workflow. Browser tests can verify navigation, forms, client-side behavior, and displayed results.
public sealed class BookingBrowserTests : IDisposable
{
private readonly IWebDriver _driver = new ChromeDriver();
[Fact]
public void Customer_can_open_booking_confirmation()
{
_driver.Navigate().GoToUrl(
"https://staging.example.test/booking");
_driver.FindElement(By.Id("package-code"))
.SendKeys("CITY-WEEKEND");
_driver.FindElement(By.Id("confirm-booking"))
.Click();
var message = _driver
.FindElement(By.Id("confirmation-message"))
.Text;
Assert.Contains("confirmed", message);
}
public void Dispose()
{
_driver.Quit();
}
}
Browser tests provide the strongest user-interface realism, but they also have the highest maintenance cost. A harmless markup change can break selectors even when the business behavior remains correct.
Use hardcoded Selenium tests for critical workflows where the test should depend as little as possible on exact page structure. Selenium IDE can record additional workflows that need to be understandable and replayable by testers or business users.
Do not automate every visual path simply because recording is possible. Compare the cost of creating and maintaining the test with how often it will run and how serious the protected failure would be.
Where AI-Generated Tests Fit
A test generator can inspect existing code and propose test cases. This is useful for finding untested branches in older code or checking whether a manually designed suite missed obvious cases.
It cannot know the intended business behavior merely by reading an implementation. If the code contains the wrong rule, generated tests may repeat and protect that wrong rule.
Use generated tests as a review assistant:
- Write requirement-driven tests manually.
- Implement the behavior using TDD.
- Ask the generator for additional cases.
- Review every proposed test against the requirement.
- Keep only cases that add meaningful protection.
Generated tests should extend a specification, not replace it.
A Practical Test Execution Order
Run faster and more deterministic checks first so failures are reported early.
Pull request
|
+--> Unit tests with xUnit
|
+--> Isolated dependency tests with Moq
|
+--> Integration tests
|
+--> Controlled ASP.NET Core functional tests
|
v
Deploy to staging
|
+--> Representative API workflows
|
+--> Critical Selenium browser journeys
|
+--> Required acceptance and performance checks
|
v
Release decision
The pipeline should stop when a required layer fails. A later browser test should not be used to compensate for missing unit coverage, and a green unit suite should not be treated as proof that the complete workflow works.
Common Mistakes
Testing only the happy path
A valid booking is only one execution path. Test unavailable packages, invalid input, boundary values, and expected exceptions.
Writing tests after the implementation and copying its branches
This can confirm the implementation without challenging whether the execution paths were designed correctly. Start with expected behavior and extreme cases.
Calling controllers directly for every functional test
Direct calls can be fast, but they bypass the HTTP and middleware pipeline. Use a controlled application when those parts matter.
Putting every scenario in Selenium
Browser tests are realistic but expensive to maintain. Keep most business rules below the user interface.
Sharing mutable fixture state
A shared fixture can make one test depend on another. Shared setup should not create hidden test ordering.
Treating staging as deterministic
A shared environment can change between runs. Put exhaustive edge cases in controlled tests and reserve staging for representative real-environment checks.
Trusting generated tests without review
Generated tests understand existing code better than business intent. Review them as suggestions.
Testing Checklist
- [ ] Acceptance behavior is written before implementation details
- [ ] Unit tests cover normal, boundary, and failure paths
- [ ]
[Theory]is used for repeated input combinations - [ ]
[Fact]is used for one specific behavior - [ ] External dependencies are isolated with focused mocks
- [ ] Async mocks use
ReturnsAsync - [ ] Important dependency calls are verified when interaction matters
- [ ] Shared fixtures do not leak mutable state between tests
- [ ] ASP.NET Core tests exercise the real request pipeline when needed
- [ ] Controlled tests start from a known application state
- [ ] Database compatibility risks are tested with the real technology
- [ ] Staging checks use representative, non-destructive scenarios
- [ ] Selenium protects only critical browser journeys
- [ ] Generated tests are reviewed against intended behavior
- [ ] The delivery pipeline stops when required tests fail
Conclusion
A green unit-test report proves only that the covered units behaved as asserted. It does not prove that the complete application satisfies the customer workflow.
Use TDD and xUnit to define business behavior early. Use Moq to isolate dependencies, fixtures to control setup, and WebApplicationFactory<T> to exercise the ASP.NET Core pipeline in a reproducible environment. Add staging checks for deployment realism and Selenium for the browser journeys whose failure would matter most.
The strongest test suite is not the one with the most tests. It is the one that assigns each important risk to the simplest layer capable of detecting it.