Most ASP.NET Core projects start with good intentions. You scaffold a new API, add a few controllers, hook up Entity Framework Core, and ship features. Fast forward three months, and suddenly writing a test requires spinning up the entire database, mocking six dependencies, and crossing your fingers that nothing breaks in CI.

The problem isn’t your testing framework. It’s that your code wasn’t structured for testability from day one.

This post shows you how to organize your ASP.NET Core API so that tests are natural, not an afterthought. You’ll learn the minimal layer separation that makes both unit and integration tests straightforward, and you’ll see working examples using WebApplicationFactory.

The Testing Problem Nobody Talks About

Here’s what happens in most ASP.NET Core APIs:

[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
    private readonly AppDbContext _db;
    
    public UsersController(AppDbContext db) => _db = db;
    
    [HttpGet]
    public async Task<IActionResult> GetAll()
    {
        var users = await _db.Users
            .Where(u => u.IsActive)
            .Select(u => new { u.Id, u.Email })
            .ToListAsync();
            
        return Ok(users);
    }
}

This looks fine. It works. But try testing it. You need a real database or an in-memory provider. The query logic is locked inside the controller. You can’t test the business rule (filtering active users) without HTTP infrastructure.

Now multiply this pattern across 50 endpoints. Your integration tests become slow. Your unit tests become impossible because everything is tightly coupled to EF Core.

The fix isn’t more mocking. It’s better boundaries.

Layer Separation That Actually Works

Here’s the folder structure I use in production APIs:

src/
├─ Api/
│   ├─ Controllers/
│   ├─ Extensions/
│   └─ Program.cs
├─ Application/
│   ├─ Interfaces/
│   ├─ Services/
│   └─ DTOs/
├─ Domain/
│   └─ Entities/
└─ Infrastructure/
    ├─ Data/
    ├─ Repositories/
    └─ Configurations/

tests/
├─ UnitTests/
└─ IntegrationTests/

Each layer has a single responsibility:

Domain holds your entities and business rules. No dependencies on anything external. Pure C# classes that represent your core model.

Application contains your use case logic. Services here orchestrate domain objects and call out to infrastructure through interfaces. This is where your testable business logic lives.

Infrastructure implements the interfaces defined in Application. Database access, file storage, external APIs. Anything that touches the outside world.

Api is your entry point. Controllers stay thin. They receive requests, call application services, and return responses. No logic here.

The key insight: your Application layer defines interfaces, and Infrastructure implements them. Not the other way around.

Dependency Direction Matters

The dependency flow looks like this:




graph TD
    A[Api Layer] --> B[Application Layer]
    B --> C[Domain Layer]
    D[Infrastructure Layer] --> B
    D -.implements.-> B

    

Layered Architecture Dependency Flow

Api depends on Application. Application depends on Domain. Infrastructure depends on Application (to implement its interfaces).

This means your core logic (Application and Domain) never references Entity Framework Core directly. It doesn’t know about SQL Server. It defines IUserRepository, and somewhere else, EfUserRepository implements it.

When you structure dependencies this way, testing becomes trivial. Unit tests mock the interfaces. Integration tests swap in real implementations.

From the field: In multi-tenant SaaS applications I’ve built, this separation allowed us to run the same test suite against SQL Server in production and SQLite in CI. The application layer didn’t care.

Setting Up Integration Tests

Here’s where ASP.NET Core shines. With WebApplicationFactory, you get a real HTTP pipeline with minimal setup.

First, make your Program class visible to tests:

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddApplicationServices();
builder.Services.AddInfrastructure(builder.Configuration);

var app = builder.Build();

app.MapControllers();
app.Run();

public partial class Program { }

That empty partial class declaration exposes Program as a type for testing.

Now create a test:

public class UsersApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public UsersApiTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetUsers_ReturnsActiveUsersOnly()
    {
        var response = await _client.GetAsync("/api/users");
        response.EnsureSuccessStatusCode();
        
        var users = await response.Content.ReadFromJsonAsync<List<UserDto>>();
        Assert.All(users, u => Assert.True(u.IsActive));
    }
}

This test starts your entire API, processes a real HTTP request, and verifies the response. No mocking. No brittle setup code.

Overriding Services for Tests

You’ll want to swap out your production database with a test database. Here’s how:

public class UsersApiTests : IClassFixture<CustomWebApplicationFactory>
{
    // test code
}

public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
            
            if (descriptor != null)
                services.Remove(descriptor);
            
            services.AddDbContext<AppDbContext>(options =>
                options.UseInMemoryDatabase("TestDb"));
        });
    }
}

Now every test runs against an in-memory database. Fast, isolated, repeatable.

The magic here is that your application code doesn’t change. Because you structured layers correctly, swapping implementations is just dependency injection configuration.

Unit Testing Application Services

Your application services are where the real logic lives. These should be pure functions that take dependencies through their constructor.

public class UserService : IUserService
{
    private readonly IUserRepository _repository;
    
    public UserService(IUserRepository repository) => _repository = repository;
    
    public async Task<List<UserDto>> GetActiveUsersAsync()
    {
        var users = await _repository.GetActiveAsync();
        return users.Select(u => new UserDto 
        { 
            Id = u.Id, 
            Email = u.Email 
        }).ToList();
    }
}

Testing this is straightforward:

[Fact]
public async Task GetActiveUsers_ReturnsOnlyActiveUsers()
{
    var mockRepo = new Mock<IUserRepository>();
    mockRepo.Setup(r => r.GetActiveAsync())
        .ReturnsAsync(new List<User> 
        { 
            new() { Id = 1, Email = "test@test.com", IsActive = true } 
        });
    
    var service = new UserService(mockRepo.Object);
    var result = await service.GetActiveUsersAsync();
    
    Assert.Single(result);
    Assert.Equal("test@test.com", result[0].Email);
}

No database. No HTTP. Just your logic under test.

Real talk: I’ve seen teams waste weeks trying to test controllers directly. Once you move logic into services with clear interfaces, those same tests take minutes to write.

Keeping Program.cs Clean

Your Program.cs should be a composition root, not a configuration dump. Move setup logic into extension methods:

// Extensions/ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddApplicationServices(
        this IServiceCollection services)
    {
        services.AddScoped<IUserService, UserService>();
        services.AddScoped<IOrderService, OrderService>();
        return services;
    }
    
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services, 
        IConfiguration config)
    {
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(config.GetConnectionString("Default")));
            
        services.AddScoped<IUserRepository, EfUserRepository>();
        return services;
    }
}

Now Program.cs stays under 20 lines, and your test factory can easily override specific layers.

Practical Guidelines

Keep these principles in mind:

Controllers should be dumb. They receive input, call a service, return output. No validation logic. No data transformation. Delegate everything.

Application services don’t know about HTTP. They work with domain objects and DTOs. They never touch HttpContext or return IActionResult.

Infrastructure is swappable. If you can’t replace your SQL Server repository with a fake in-memory version without changing application code, your boundaries are wrong.

Tests mirror structure. Unit tests go in UnitTests/Application/Services. Integration tests go in IntegrationTests/Api/Controllers. Keep it organized.

Avoid test-specific code in production. No if (isTest) checks. No public setters just for tests. Fix your structure instead.

The Payoff

When you structure your API this way, several things happen:

Your integration tests run in under 10 seconds. You can execute the full suite on every commit without waiting.

Refactoring becomes safe. Change your database schema? Update the repository. Swap EF Core for Dapper? Touch only Infrastructure. Your tests still pass.

New developers onboard faster. The folder structure tells them where code belongs. They write testable code by default because the architecture guides them.

Most importantly, you stop fighting your tools. Testing isn’t painful when your code naturally separates concerns.

A testable architecture isn’t one buried under mocks and abstractions. It’s one where tests feel obvious because the structure invites them.

References

  1. Microsoft Docs: Integration tests in ASP.NET Core
  2. Martin Fowler: Dependency Inversion Principle
  3. Microsoft Docs: Dependency injection in ASP.NET Core
  4. xUnit Documentation: Shared Context between Tests
  5. Clean Architecture by Robert C. Martin

Related Posts