Back to articles

Using a Test Project to Run and Debug a .NET Application - Part 1

The main goal of this article is to walk through the first steps of building a test project from scratch and using that test project as the application’s entry point. This makes it easier to run real business logic and database flows through automated tests, instead of depending on web layers or fragile manual checks.

9 min read

In this series, I want to explore using a test project as the entry point for your .NET application.

I’ve seen, and experienced myself, developers go through all kinds of pain just to run a system. You probably know how it goes: a company has many projects spread across different solutions, and you get asked to fix a simple bug. Then you spend more time figuring out how to run and debug that part of the system than actually fixing it.

After you make what looks like a small fix, the only way to check that it works is through manual testing because there are no unit or integration tests around that area. By the end of the day, you’re ready to deploy the fix to production, and you have to do it because that small bug could have a big impact on the business. And… congrats, you fixed the problem after putting in some overtime. Then, the next morning, your phone rings early because the team found that your fix, while solving the original problem, introduced a new critical one, and now the cycle starts all over again.

If you are still working this way, it’s worth rethinking it. This approach is too slow, especially now that AI is making code much cheaper. It can seriously hurt a business’s ability to move fast. The way out is to have strong confidence in your automated tests.

Many developers still see tests as a burden because testing is often treated as something separate from development. In this series, I want to show a practical way to make tests part of the development process. I won’t go too deep into theory because I want to focus on hands-on work.

The idea is simple: use tests as your main way to develop from the beginning. I’ve used this approach successfully with my team. In practice, that means you do not rely on a web layer or any other entry point to your application other than the test project. Forget about API and http during the phase where the focus is on business rules. Forget about building your backend while depending on a front-end. The focus should be on tests and business logic. That way, from day one, you run, debug, and validate your application through tests.

Initial project setup

Please find the source code here. I’ll walk through the commits and explain the main parts rather than turn this into a step-by-step tutorial. That way, we can focus on the core pieces instead of spending too much time on the usual .Net boilerplate setup.

The commit #b14f8aba contains only the simple and small service HelloWorldService along with its tests HelloWorldServiceTests. This is a test project using xUnit and it is all in one file for simplicity:

public sealed class HelloWorldService
{
    public string GetMessage()
        => "Hello, World!";
}

public sealed class HelloWorldServiceTests
{
    [Fact]
    public void Echo_ReturnsHelloWorld()
    {
        // Create new service collection (DI container)
        var services = new ServiceCollection();

        // Register the HelloWorldService
        services
            .AddTransient<HelloWorldService>();

        // Build service provider
        var serviceProvider = services
            .BuildServiceProvider();

        // Get a HelloWorldService instance
        var helloWorldService = serviceProvider
            .GetRequiredService<HelloWorldService>();

        // Call the GetMessage method from the HelloWorldService
        var result = helloWorldService
            .GetMessage();

        // Assert result
        Assert
            .Equal("Hello, World!", result);
    }
}

This example is incredibly simple, but it shows an important idea: an app “entry point” (here it’s a test project) is mainly the place where you build a DI container and register the services and configuration your app will use.

Once you really get that, the next parts of this series will make a lot more sense.

Note that the package Microsoft.Extensions.DependencyInjection was installed so the test project can use the ServiceCollection

Creating the Application layer

Commit #3465be28 introduces the class library project Tsrc.Application, which is where the business logic lives. Since a class library is not executable, it can be used through different entry points. In most tutorials, you would see a Tsrc.Web project referencing the application layer. Here, Tsrc.Tests plays that role instead.

This commit includes the following changes:

  1. After creating the class library project Tsrc.Application, I added the package Microsoft.Extensions.DependencyInjection
  2. Moved the HelloWorldService into the Tsrc.Application project
  3. Created the StartUp class. This is where services from the application layer are registered
public static class StartUp
{
    public static IServiceCollection AddApplication(
        this IServiceCollection services)
    {
        services
        .AddTransient<HelloWorldService>();

        return services;
    }
}
  1. Added Tsrc.Application as a dependency of the Tsrc.Tests project
  2. Removed the package Microsoft.Extensions.DependencyInjection from the test project, since the application layer now provides what is needed
  3. Updated the Echo_ReturnsHelloWorld test to wire up DI with services.AddApplication(); instead of registering the application service directly in the test project. That matters because if you later register the application layer from a web project, for example, you would do it the same way
public sealed class HelloWorldServiceTests
{
    [Fact]
    public void Echo_ReturnsHelloWorld()
    {
        // Create new service collection (DI container)
        var services = new ServiceCollection();

        // Register the Application project
        services
            .AddApplication();

        // Build service provider
        var serviceProvider = services
            .BuildServiceProvider();

        // Get a HelloWorldService instance
        var helloWorldService = serviceProvider
            .GetRequiredService<HelloWorldService>();

        // Call the GetMessage method from the HelloWorldService
        var result = helloWorldService
            .GetMessage();

        // Assert result
        Assert
            .Equal("Hello, World!", result);
    }
}

Adding a Real Database to the Application

Using Testcontainers

In this section, I’ll show the steps needed to run a PostgreSQL database for tests. A common trap here is using Entity Framework’s InMemory provider for “integration” tests. I don’t recommend it. It behaves differently from a real database, and it also does not help if your app uses something other than Entity Framework to access data, like Dapper.

Commit #71dea38e introduces DbTestsContainer and a test to check that the Docker database instance is created correctly. When you run the test Test_Db_Initialization, you’ll see two containers being created and then removed. One is the database container itself. The other is used by Testcontainers.PostgreSql to make sure containers created by the Testcontainers library are cleaned up properly.

Note that the Testcontainers.PostgreSql NuGet package was installed in Tsrc.Tests.

public sealed class DbTestsContainer
{
    private readonly TimeSpan _containerStartupTimeout = TimeSpan
        .FromMinutes(3);

    private readonly PostgreSqlContainer _container
        = new PostgreSqlBuilder("postgres:17-alpine")
            .WithDatabase("testdb")
            .WithUsername("testuser")
            .WithPassword("testpass")
            .Build();

    public async Task<string> StartAsync()
    {
        try
        {
            await _container
                .StartAsync()
                .WaitAsync(_containerStartupTimeout);

            return _container
                .GetConnectionString();
        }
        catch (TimeoutException)
        {
            throw new TimeoutException("The container did not start within the expected timeout.");
        }
    }
}
[Fact]
public async Task Test_Db_Initialization()
{
    var db = new DbTestsContainer();
    var connectionString = await db.StartAsync();
    Assert.NotNull(connectionString);
}

The setup is pretty simple, but you already get a real database that is created and disposed of automatically.

Entity Framework setup

Commit #eeb4e256 introduces the following:

  1. The NuGet package Npgsql.EntityFrameworkCore.PostgreSQL in the Tsrc.Application project
  2. The StaffMember entity and its EF configuration StaffMemberEfConfig
public sealed class StaffMember
{
    public Guid Id { get; set; }
    public string Name { get; set; }
}

internal sealed class StaffMemberEfConfig
    : IEntityTypeConfiguration<StaffMember>
{
    public void Configure(EntityTypeBuilder<StaffMember> builder)
    {
        builder
            .ToTable("staff_members");

        builder
            .HasKey(e => e.Id);

        builder
            .Property(e => e.Id)
            .ValueGeneratedOnAdd();

        builder
            .Property(e => e.Name)
            .HasMaxLength(255)
            .IsRequired();
    }
}
  1. The application DbContext
public class AppDbContext(
    DbContextOptions<AppDbContext> options)
    : DbContext(options)
{
    public DbSet<StaffMember> StaffMembers
        => Set<StaffMember>();

    protected override void OnModelCreating(
        ModelBuilder modelBuilder)
    {
        modelBuilder
            .ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());

        base
            .OnModelCreating(modelBuilder);
    }
}

EF migrations setup

We’ll use migrations here because they make development much smoother. To keep things simple, this example uses the easiest setup: letting the application run the migrations. That works especially well during development. Whether you should also use that approach in production is a separate discussion that won’t be covered in this article.

Our Tsrc.Tests project will be the entry point for creating migrations. Here is a quick walkthrough of commit #6041c01e:

  1. Add the Microsoft.EntityFrameworkCore.Design NuGet package to the Tsrc.Tests project
  2. Create a design-time factory. This class is required so migrations can be created with the Tests project as the entry point
public sealed class AppDbContextFactory
    : IDesignTimeDbContextFactory<AppDbContext>
{
    public AppDbContext CreateDbContext(string[] args)
    {
        var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();

        // This connection string is only used for design-time operations (migrations)
        // At runtime, your application will use the actual connection string

        var migrationsAssembly = typeof(AppDbContext)
            .Assembly
            .GetName()
            .Name;

        optionsBuilder.UseNpgsql(
            "Host=localhost;Database=tsrcapp_dev;Username=postgres;Password=postgres",
            b => b.MigrationsAssembly(migrationsAssembly)
                .MigrationsHistoryTable("__EFMigrationsHistory", "public"));

        return new AppDbContext(optionsBuilder.Options);
    }
}
  1. The bash command add-migration.sh under the Tsrc.Tests project makes creating migrations easier. There are other options, like using an IDE plugin, but I prefer a script. If you are on Windows and cannot run bash, you will need to create a PowerShell equivalent.
read -p "Migration name: " MIGRATION_NAME

if [[ -z "$MIGRATION_NAME" ]]; then
  echo "Error: migration name cannot be empty."
  exit 1
fi

dotnet ef migrations add "$MIGRATION_NAME" \
  --project ../Tsrc.Application/Tsrc.Application.csproj \
  --startup-project . \
  --output-dir Infrastructure/Persistence/EfMigrations \
  --context AppDbContext
  1. StartUp in Tsrc.Application was updated to configure AppDbContext and accept a database connection string through ApplicationConfiguration
public sealed record ApplicationConfiguration(
    string ConnectionString);

public static class StartUp
{
    public static IServiceCollection AddApplication(
        this IServiceCollection services,
        ApplicationConfiguration configuration)
    {
        services
            .AddTransient<HelloWorldService>()
            .AddDbContext<AppDbContext>(options =>
            {
                options.UseNpgsql(configuration.ConnectionString);
            });

        return services;
    }
}
  1. Add the two tests below to verify that migrations create the database and the table for the StaffMember entity
public sealed class AppDbContextTests
{
    [Fact]
    public async Task AppDbContext_Service_Is_Resolvable()
    {
        var services = new ServiceCollection();

        var dbTestsContainer = new DbTestsContainer();

        var connectionString = await dbTestsContainer
            .StartAsync();

        var applicationConfiguration = new ApplicationConfiguration(
            connectionString);

        services
            .AddApplication(applicationConfiguration);

        var serviceProvider = services
            .BuildServiceProvider();

        var dbContext = serviceProvider
            .GetRequiredService<AppDbContext>();

        Assert
            .NotNull(dbContext);
    }

    [Fact]
    public async Task DbContext_StaffMembersTable_ShouldBeEmptyInitially()
    {
        var services = new ServiceCollection();

        var dbTestsContainer = new DbTestsContainer();

        var connectionString = await dbTestsContainer
            .StartAsync();

        var applicationConfiguration = new ApplicationConfiguration(
            connectionString);

        services
            .AddApplication(applicationConfiguration);

        var serviceProvider = services
            .BuildServiceProvider();

        var dbContext = serviceProvider
            .GetRequiredService<AppDbContext>();

        await dbContext
            .Database
            .MigrateAsync();

        var result = await dbContext
            .StaffMembers
            .CountAsync();

        Assert
            .Equal(0, result);
    }
}
  1. Final cleanup
    • Removed the test class HelloWorldServiceTests and the HelloWorldService, since they are no longer relevant
    • Moved AppDbContext and the EF configuration into the Infrastructure > Persistence folder so they sit closer to the migrations

Conclusion

This is all pretty standard and familiar .NET setup. But from the first HelloWorldService example to the tests against a real PostgreSQL database, the main point stays the same: everything is built and driven through tests.

Even the simple HelloWorldService was created with a test-first mindset, even though we removed it later as the example grew. That is the approach I want to keep building on in this series.

In Part 2, we’ll start implementing real features using this test-driven approach and continue evolving the test framework.