.net testing

Automated Testing of a .NET and Angular Application: From Unit Tests to E2E

Modern software development is difficult to imagine without reliable automated testing.

There are many types of automated tests, and the choice of a particular set depends on the goals, architecture, and scale of the project. The better an application is tested, the lower the probability that changes will introduce unexpected errors. However, tests also require resources: they need to be designed, written, executed, and maintained.

Therefore, finding the right balance between developing new functionality and expanding test coverage remains relevant for almost every project.

In this article, we will examine an approach to testing small and medium-sized applications. The presented set of tests and its implementation are based on my practical experience and are not intended to be the only correct solution.

For small projects, I usually use three levels of automated testing:

  • unit tests;
  • integration API tests;
  • E2E tests.

We will use the current version of serdg.net as the application under test.

Technology Stack

Application Technology Stack

Angular 21 → .NET 10 Web API → Entity Framework Core → Microsoft SQL Server

Testing Technology Stack

NUnit + WebApplicationFactory + Playwright + Moq + SQLite

Ideally, these three levels form a kind of testing pyramid: there should be many unit tests, fewer integration tests, and E2E tests should cover only the most important user scenarios.

Unit Tests

We will not focus on unit tests in detail in this article, as enough has already been written about their purpose, structure, and implementation approaches.

Let us only define their main area of responsibility. Unit tests verify individual parts of an application in isolation from the database, file system, network, and other external dependencies.

They are especially useful for code that:

  • contains complex business logic;
  • performs critical calculations;
  • has many edge cases;
  • must be verified quickly after every change.

However, unit tests alone are not sufficient. Even when every application component works correctly in isolation, errors may still occur when those components interact with each other.

This is where integration API tests become useful.

Integration API Tests

In integration API tests, the server-side part of the application is treated as a single system.

A test sends an HTTP request and analyses the HTTP response without directly calling controllers, services, or repositories. From this perspective, the application can be treated as a black box: we control the input data and verify the result.

This approach makes it possible to test several application layers at once:

  • routing;
  • middleware;
  • authorization;
  • input validation;
  • controllers;
  • business logic;
  • serialization;
  • database interaction.

At the same time, the tests must remain executable, predictable, and repeatable not only locally, but also in a CI/CD environment.

ASP.NET Core provides WebApplicationFactory for this purpose.

Configuring WebApplicationFactory

WebApplicationFactory<TEntryPoint> makes it possible to create a test host based on the configuration of the main application.

By default, the factory uses TestServer, allowing HTTP requests to be executed without starting a separate network process. At the same time, the test application goes through almost the same configuration process as the production application.

The main point of interest is the following method:


protected override void ConfigureWebHost(IWebHostBuilder builder)

It can be used to modify the environment, configuration, logging, and registered dependencies.

Here is an example of the factory configuration:


protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    // Set the test environment.
    builder.UseEnvironment("Testing");

    // Load environment-specific settings from the application settings file.
    builder.ConfigureAppConfiguration((context, configBuilder) =>
    {
        configBuilder.AddJsonFile(
            "appsettings.Test.json",
            optional: false,
            reloadOnChange: false);
    });

    // Configure logging.
    builder.ConfigureLogging(logging =>
    {
        logging.ClearProviders();
        logging.AddConsole();
        logging.SetMinimumLevel(LogLevel.Warning);
    });

    // Load JWT settings.
    builder.ConfigureServices((context, services) =>
    {
        JwtSettings = context.Configuration
            .GetSection("JWTSettings")
            .Get<JwtSettings>()!;
    });

    builder.ConfigureTestServices(services =>
    {
        _configureTestServices?.Invoke(services);

        // Replace the production database dependencies.
        services.RemoveAll<SdContext>();
        services.RemoveAll<DbContextOptions<SdContext>>();
        services.RemoveAll<DbConnection>();
        services.RemoveAll<IDbContextFactory<SdContext>>();

        // Create and open the database connection.
        _connection = new SqliteConnection("DataSource=:memory:");
        _connection.Open();

        services.AddSingleton<DbConnection>(_connection);
        services.AddDbContextFactory<SdContext>((serviceProvider, options) =>
        {
            var connection = serviceProvider.GetRequiredService<DbConnection>();
            options.UseSqlite(connection);
        });

        // Additional test-specific service configuration.
    });
}

There are several important points to consider here.

A Separate Test Environment

The following call:

builder.UseEnvironment("Testing");

makes it possible to distinguish the test application from the Development and Production environments.

This is useful when certain parts of the configuration should only be enabled during testing.

A Separate Configuration File

The appsettings.Test.json file contains parameters intended exclusively for tests.

For example, it may define:

  • test JWT settings;
  • logging settings;
  • feature flag values;
  • other test environment parameters.

Production application secrets should not be used in tests.

Replacing the Database

The production Microsoft SQL Server configuration is replaced with an in-memory SQLite database.

This is not the same as using the EF Core InMemory Provider. SQLite remains a relational database and supports many SQL-related constraints and features:

  • foreign keys;
  • unique indexes;
  • transactions;
  • SQL queries;
  • a relational data model.

As a result, the behaviour of the tests is closer to the behaviour of the actual database.

An important feature of an in-memory SQLite database is that it exists only while its connection remains open. For this reason, the connection is created once, registered as a singleton, and kept open until the factory is disposed of.

Base Class for API Tests

The next step is to create a base class from which the controller tests will inherit.

It contains:

  • a WebApplicationFactory instance;
  • an HttpClient;
  • common test data preparation;
  • authorization configuration;
  • replacement of external dependencies;
  • resource cleanup after each test.

public abstract class BaseController
{
    protected HttpClient Client { get; private set; } = null!;
    protected CustomWebApplicationFactory<Program> Factory { get; private set; } = null!;
    protected Mock<ISmtpClientWrapper> SmtpClientWrapper { get; private set; } = null!;

    [SetUp]
    public async Task SetUp()
    {
    SmtpClientWrapper = new Mock<ISmtpClientWrapper>(MockBehavior.Strict);

    Factory = new CustomWebApplicationFactory<Program>(services =>
    {
        services.RemoveAll<ISmtpClientWrapper>();
        services.AddSingleton<ISmtpClientWrapper>(SmtpClientWrapper.Object);
    });

    Client = Factory.CreateClient(
        new WebApplicationFactoryClientOptions());

    // Reset the database to its initial state before every test.
    await Factory.ResetDatabaseAsync();

    AuthorizeClient();
}

[TearDown]
public void TearDown()
{
    Client.Dispose();
    Factory.Dispose();
}
// ...
}

The [SetUp] and [TearDown] methods are standard NUnit mechanisms. The SetUp() method is executed before every test, while TearDown() is executed after it.

During test preparation, we replace the real implementation of ISmtpClientWrapper with a Moq object. This prevents the tests from sending actual emails.

The same approach can be used to replace:

  • SMTP clients;
  • CAPTCHA services;
  • payment systems;
  • cloud storage services;
  • external HTTP APIs;
  • other integrations outside the boundaries of the application under test.

Resetting the Database

The SQLite connection remains open for the entire lifetime of the factory. However, before every test, the database is returned to its initial state:

await Factory.ResetDatabaseAsync();

The ResetDatabaseAsync() method may:

  1. delete the existing schema;
  2. recreate it;
  3. apply migrations;
  4. add an initial set of test data.

The exact implementation depends on the architecture of the project.

The main requirement is that every test must start with a known and predictable database state.

Tests should not depend on their execution order or on data left behind by previous scenarios.

API Test Example

After this configuration, an integration test looks relatively simple:


[TestFixture]
[NonParallelizable]
public class TopicControllerTests : BaseController
{
    private const string TopicBasePath = "/api/Topic";


    [Test]
    public async Task GetAll_WithValidData_ReturnsValidResponse()
    {
        var expectedCount = TestData.GetTopics().Count();

        using var response = await Client.GetAsync(TopicBasePath);

        response.Should().NotBeNull();
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var responseContent =
            await response.Content.ReadAsStringAsync();

        var topics =
            JsonConvert.DeserializeObject<TopicResponse>(responseContent);

        topics.Should().NotBeNull();
        topics!.Items.Should().NotBeNull();
        topics.Items.Count.Should().Be(expectedCount);
    }
}

The test executes a real HTTP request against the test application and verifies:

  • the response status;
  • whether the response can be deserialized;
  • whether the expected data is present;
  • the number of returned items.

Unlike a unit test, this test does not manually create a controller instance or call its method directly. The request passes through the application’s HTTP pipeline.

Parallel Test Execution

In the example above, the test class is marked with the following attribute:

[NonParallelizable]

This means that NUnit will not execute the tests in this fixture in parallel with other tests.

This approach is useful when tests share resources such as:

  • a single database connection;
  • one initial data set;
  • shared static settings;
  • one test server instance;
  • fixed network ports.

When there are many tests, sequential execution may significantly increase the total execution time. Therefore, NonParallelizable should be treated as a practical starting point rather than a mandatory rule.

To support parallel execution, each test or fixture must be provided with isolated resources: a separate database, a separate factory, and, when necessary, a separate network port.

Nested test classes should also be used only when they genuinely improve the test structure. NUnit supports them, but they may make the fixture lifecycle and the execution order of SetUp and TearDown methods more difficult to understand.

After completing this stage, we have an API whose behaviour is verified through HTTP and produces repeatable results both locally and in CI/CD.

Moving to E2E Tests

Now we can proceed to E2E testing.

A significant part of the required infrastructure has already been created:

  • the test server application can be started;
  • the database is created and populated;
  • external dependencies can be replaced with mock objects;
  • the server returns predictable results.

The remaining step is to add the user interface and perform checks through it instead of calling the API directly.

Because the Angular application supports Server-Side Rendering, an Angular SSR server must also be started for the E2E tests.

Starting the Angular SSR Server

The AngularSsrServer class is used to manage the Node.js process.


public sealed class AngularSsrServer : IAsyncDisposable
{
    private const string AngularSsrServerPathKey = "AngularSsr:ServerPath";
    private const string E2eSettingsFileName = "appsettings.E2E.json";
    private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30);

    private readonly HttpClient _httpClient;
    private readonly Process _process;
    private readonly StringBuilder _stderr = new();
    private readonly StringBuilder _stdout = new();

    private AngularSsrServer(Process process, Uri baseAddress)
    {
        _process = process;
        BaseAddress = baseAddress;

        _httpClient = new HttpClient
        {
            BaseAddress = baseAddress,
            Timeout = TimeSpan.FromSeconds(2)
        };
    }

    public Uri BaseAddress { get; }

    public string ProcessOutput => GetProcessOutput();

    public static async Task<AngularSsrServer> StartAsync(
        Uri apiBaseAddress)
    {
        var serverPath = GetAngularSsrServerPath();
        var port = GetFreeTcpPort();
        var baseAddress = new Uri($"http://127.0.0.1:{port}");

        var startInfo = new ProcessStartInfo
        {
            FileName = "node",
            WorkingDirectory = Path.GetDirectoryName(serverPath)!,
            UseShellExecute = false,
            RedirectStandardError = true,
            RedirectStandardOutput = true
        };

        startInfo.ArgumentList.Add(serverPath);

        startInfo.Environment["PORT"] = port.ToString();

        startInfo.Environment["SERDG_API_BASE_URL"] =
            apiBaseAddress.ToString().TrimEnd('/');

        startInfo.Environment["NG_ALLOWED_HOSTS"] =
            "127.0.0.1,localhost";

        startInfo.Environment["NODE_ENV"] = "production";
        startInfo.Environment["NO_COLOR"] = "1";

        var process = Process.Start(startInfo)
            ?? throw new InvalidOperationException(
                "Failed to start Angular SSR server process.");

        var server = new AngularSsrServer(process, baseAddress);

        process.OutputDataReceived +=
            (_, args) => server.AppendOutput(args.Data);

        process.ErrorDataReceived +=
            (_, args) => server.AppendError(args.Data);

        process.BeginOutputReadLine();
        process.BeginErrorReadLine();

        try
        {
            await server.WaitUntilReadyAsync();
            return server;
        }
        catch
        {
            await server.DisposeAsync();
            throw;
        }
    }

    // Other implementation details are omitted.
}

The class performs several tasks:

  1. It locates the compiled Angular SSR server file.
  2. It selects an available TCP port.
  3. It starts the Node.js process.
  4. It passes the test API URL through an environment variable.
  5. It redirects the standard output and error streams.
  6. It waits until the server is ready to accept requests.
  7. It terminates the process after the tests have completed.

Using a dynamically selected free port helps avoid conflicts with locally running applications and other test processes.

An Important TestServer Limitation

The HttpClient returned by CreateClient() communicates with it inside the current process.

A separate Node.js process cannot access such a server using an address such as:

http://127.0.0.1:5000

Therefore, for SSR and E2E tests, CustomWebApplicationFactory must start the application through Kestrel or use another mechanism that provides a real HTTP address.

In this project, the address is available through:

Factory.ServerAddress

This is the address passed to the Angular SSR server.

When the factory uses only the standard TestServer, passing ServerAddress to an external Node.js process will not work.

Starting the SSR Server in SetUp

The SSR server is started at the end of the SetUp() method:


[SetUp]
public async Task SetUp()
{
    // Configure the test API and reset the database.
    ServerAddress = Factory.ServerAddress;
    SsrServer = await AngularSsrServer.StartAsync(ServerAddress);
}

As a result, the Angular SSR server receives the test API address and can access it while rendering pages on the server.

Playwright Integration

Because the checks are now performed through a browser, the base E2E test class inherits from PageTest, provided by the Microsoft.Playwright.NUnit package.


public abstract class BaseTests : PageTest
{
    protected AngularSsrServer SsrServer { get; private set; } = null!;
    protected Uri ServerAddress { get; private set; } = null!;

    // Test initialization and cleanup.
}

PageTest provides access to the main Playwright objects, including:

  • Browser;
  • Context;
  • Page;
  • Playwright.

The NUnit lifecycle must also be taken into account. When base and derived classes define their own [SetUp] and [TearDown] methods, it is important to ensure that they do not hide each other and that they are executed in the expected order.

The cleanup method is extended to terminate the SSR process:


[TearDown]
public async Task TearDown()
{
    if (SsrServer is not null)
    {
        await SsrServer.DisposeAsync();
    }

    // Dispose of other test resources.
}

The Node.js process must be terminated even when a test fails. Otherwise, several test runs may leave background processes running and network ports occupied.

Testing Server-Side Rendering

With the current configuration, a test may look as follows:

[TestFixture]


[NonParallelizable]
public class TopicTests : BaseTests
{
    [Test]
    public async Task Home_WhenServerSideRendered_ContainsArticlesInInitialHtml()
    {
    await using var context = await Browser.NewContextAsync( new BrowserNewContextOptions
    {
        // Disable JavaScript to verify the SSR output.
        JavaScriptEnabled = false
    });

        var page = await context.NewPageAsync();

        var response = await page.GotoAsync(
            SsrServer.Url(TopicTestUrls.Home),
            new PageGotoOptions
            {
                WaitUntil = WaitUntilState.DOMContentLoaded
            });

        response.Should().NotBeNull();
        response!.Status.Should().Be((int)HttpStatusCode.OK);

        await RunWithSsrDiagnosticsAsync(async () =>
        {
            await ExpectHomeArticlesAsync(page);

            var html = await page.ContentAsync();

            html.Should().Contain(
                TopicTestLocators.Topic10Title);
        });
    }


}

JavaScript is intentionally disabled in this test:

JavaScriptEnabled = false

This makes it possible to verify that article titles are already present in the HTML generated by the SSR server and are not added by Angular after the page has been loaded in the browser.

This scenario is especially important for verifying:

  • search engine indexing;
  • Open Graph metadata;
  • link previews;
  • availability of the main content without executing JavaScript;
  • correct server-side rendering.

Such scenarios include:

  • navigating between pages;
  • filling in forms;
  • signing in;
  • submitting data;
  • handling errors;
  • uploading images;
  • displaying data received from the API.

After adding the required user scenarios, the entire chain is covered by tests:

Browser → Angular → SSR → .NET API → Entity Framework Core → SQLite

Running E2E Tests in GitHub Actions

The final step is to add the tests to the CI/CD pipeline.

After building the project, the Playwright browser and its system dependencies must be installed:


- name: Install Playwright browsers
working-directory: ${{ env.API_DIR }}
run: >
pwsh
./E2ETests/bin/${{ env.CONFIGURATION }}/net10.0/playwright.ps1
install
--with-deps
chromium

The E2E tests can then be executed:


- name: Run E2E tests
working-directory: ${{ env.API_DIR }}
run: >
dotnet test
./E2ETests/E2ETests.csproj
--configuration "${{ env.CONFIGURATION }}"
--no-build
--settings ./E2ETests/playwright.runsettings
--logger "trx;LogFileName=e2e-test-results.trx"
--results-directory ./TestResults/e2e

The following parameter:

--no-build

means that the project must already have been built during one of the previous workflow steps.

The build is also required for the following file to be generated:

playwright.ps1

In this configuration, only Chromium is installed. This reduces the workflow execution time.

When cross-browser compatibility needs to be verified, Firefox and WebKit can be installed as well. However, for a small project, running the tests only in Chromium is often a reasonable compromise between coverage and execution time.

The E2E tests are now executed every time the corresponding GitHub Actions workflow runs. When one of the critical scenarios fails, the build does not continue to the following stages, such as creating and publishing a Docker image.

Conclusion

As a result, we have implemented three levels of automated testing.

Unit tests verify individual parts of the business logic quickly and in isolation.

Integration API tests start the server application and verify the complete HTTP request processing chain, including routing, authorization, controllers, services, and database interaction.

E2E tests add Angular, SSR, and a real browser, allowing the application to be verified from the user’s perspective.

For a small or medium-sized project, this set of tests provides a good balance between quality, execution speed, and maintenance costs.

At the same time, there is no need to verify every possible scenario at all three levels. It is important to select the most appropriate level for each case:

  • individual business logic should be verified with unit tests;
  • HTTP API behaviour and interaction between server-side components should be verified with integration tests;
  • critical user scenarios should be verified with E2E tests.

This way, tests do not become an end in themselves. Instead, they remain a tool that helps develop the application safely and detect errors more quickly.