Dotnet Integration Testing logo

Dotnet Integration Testing

Community
ronnythedev
dotnet-integration-testing

Configures integration tests with WebApplicationFactory and Testcontainers. Provides test database setup, authentication helpers, and utilities for testing API endpoints with real dependencies.

Overview

Publisherronnythedev
Repositorydotnet-clean-architecture-skills
Skill namedotnet-integration-testing
Stars
77
Forks
16
Bundled files
Instructions only
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • Self-contained

    Everything the model needs lives in the instructions — no extra files to sync.

  • Open source

    Published by ronnythedev on GitHub. Read the source before you install it.

Installation

Install the Dotnet Integration Testing AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/ronnythedev/dotnet-clean-architecture-skills/tree/main/skills/22-dotnet-integration-testing
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/ronnythedev/dotnet-clean-architecture-skills.git /tmp/dotnet-clean-architecture-skills
mkdir -p .claude/skills
cp -r /tmp/dotnet-clean-architecture-skills/skills/22-dotnet-integration-testing .claude/skills/dotnet-integration-testing
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Dotnet Integration Testing in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Dotnet Integration Testing on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Dotnet Integration Testing is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

Integration Test Setup

Overview

Integration tests verify the full request pipeline:

  • WebApplicationFactory - In-memory test server
  • Testcontainers - Real PostgreSQL in Docker
  • Respawn - Fast database cleanup between tests
  • Authentication helpers - Test with different users/roles

Quick Reference

ComponentPurpose
IntegrationTestWebAppFactoryCustom test server factory
BaseIntegrationTestBase class for all tests
RespawnerDatabase cleanup utility
TestAuthHandlerFake authentication handler

Test Project Structure

tests/
└── {name}.Api.IntegrationTests/
    ├── Infrastructure/
    │   ├── IntegrationTestWebAppFactory.cs
    │   ├── BaseIntegrationTest.cs
    │   ├── TestAuthHandler.cs
    │   └── FakeUserContext.cs
    ├── {Feature}/
    │   ├── Create{Entity}Tests.cs
    │   └── Get{Entity}Tests.cs
    └── {name}.Api.IntegrationTests.csproj

Template: Test Project File

xml
<!-- tests/{name}.Api.IntegrationTests/{name}.Api.IntegrationTests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="FluentAssertions" Version="6.12.0" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
    <PackageReference Include="Respawn" Version="6.1.0" />
    <PackageReference Include="Testcontainers.PostgreSql" Version="3.6.0" />
    <PackageReference Include="xunit" Version="2.6.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.5.4">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
    </PackageReference>
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\{name}.api\{name}.api.csproj" />
    <ProjectReference Include="..\..\src\{name}.infrastructure\{name}.infrastructure.csproj" />
  </ItemGroup>

</Project>

Template: WebApplicationFactory

csharp
// tests/{name}.Api.IntegrationTests/Infrastructure/IntegrationTestWebAppFactory.cs
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Testcontainers.PostgreSql;
using {name}.infrastructure;

namespace {name}.Api.IntegrationTests.Infrastructure;

public class IntegrationTestWebAppFactory 
    : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
        .WithImage("postgres:15-alpine")
        .WithDatabase("testdb")
        .WithUsername("postgres")
        .WithPassword("postgres")
        .Build();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            // ═══════════════════════════════════════════════════════════════
            // REPLACE DATABASE WITH TEST CONTAINER
            // ═══════════════════════════════════════════════════════════════
            
            services.RemoveAll(typeof(DbContextOptions<ApplicationDbContext>));

            services.AddDbContext<ApplicationDbContext>(options =>
            {
                options.UseNpgsql(_dbContainer.GetConnectionString());
            });

            // ═══════════════════════════════════════════════════════════════
            // REPLACE AUTHENTICATION WITH TEST HANDLER
            // ═══════════════════════════════════════════════════════════════
            
            services.AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
                options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
            })
            .AddScheme<TestAuthSchemeOptions, TestAuthHandler>(
                TestAuthHandler.SchemeName,
                options => { });

            // ═══════════════════════════════════════════════════════════════
            // REPLACE EXTERNAL SERVICES WITH FAKES
            // ═══════════════════════════════════════════════════════════════
            
            // services.RemoveAll<IEmailService>();
            // services.AddSingleton<IEmailService, FakeEmailService>();
        });

        builder.UseEnvironment("Testing");
    }

    public async Task InitializeAsync()
    {
        await _dbContainer.StartAsync();
        
        // Apply migrations
        using var scope = Services.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
        await dbContext.Database.MigrateAsync();
    }

    public new async Task DisposeAsync()
    {
        await _dbContainer.StopAsync();
    }
}

Template: Test Authentication Handler

csharp
// tests/{name}.Api.IntegrationTests/Infrastructure/TestAuthHandler.cs
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace {name}.Api.IntegrationTests.Infrastructure;

public class TestAuthSchemeOptions : AuthenticationSchemeOptions
{
    public Guid? UserId { get; set; }
    public string? Email { get; set; }
    public string[]? Roles { get; set; }
    public string[]? Permissions { get; set; }
}

public class TestAuthHandler : AuthenticationHandler<TestAuthSchemeOptions>
{
    public const string SchemeName = "TestScheme";
    public const string TestUserIdHeader = "X-Test-User-Id";
    public const string TestUserEmailHeader = "X-Test-User-Email";
    public const string TestUserRolesHeader = "X-Test-User-Roles";
    public const string TestUserPermissionsHeader = "X-Test-User-Permissions";

    public TestAuthHandler(
        IOptionsMonitor<TestAuthSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder)
        : base(options, logger, encoder)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        // Check for test headers
        if (!Request.Headers.TryGetValue(TestUserIdHeader, out var userIdHeader))
        {
            return Task.FromResult(AuthenticateResult.NoResult());
        }

        if (!Guid.TryParse(userIdHeader, out var userId))
        {
            return Task.FromResult(AuthenticateResult.Fail("Invalid user ID"));
        }

        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, userId.ToString()),
            new("sub", userId.ToString())
        };

        // Add email
        if (Request.Headers.TryGetValue(TestUserEmailHeader, out var emailHeader))
        {
            claims.Add(new Claim(ClaimTypes.Email, emailHeader.ToString()));
            claims.Add(new Claim("email", emailHeader.ToString()));
        }

        // Add roles
        if (Request.Headers.TryGetValue(TestUserRolesHeader, out var rolesHeader))
        {
            foreach (var role in rolesHeader.ToString().Split(','))
            {
                claims.Add(new Claim(ClaimTypes.Role, role.Trim()));
            }
        }

        // Add permissions
        if (Request.Headers.TryGetValue(TestUserPermissionsHeader, out var permissionsHeader))
        {
            foreach (var permission in permissionsHeader.ToString().Split(','))
            {
                claims.Add(new Claim("permission", permission.Trim()));
            }
        }

        var identity = new ClaimsIdentity(claims, SchemeName);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, SchemeName);

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

Template: Base Integration Test

csharp
// tests/{name}.Api.IntegrationTests/Infrastructure/BaseIntegrationTest.cs
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Respawn;
using {name}.infrastructure;

namespace {name}.Api.IntegrationTests.Infrastructure;

[Collection("Integration")]
public abstract class BaseIntegrationTest : IAsyncLifetime
{
    protected readonly IntegrationTestWebAppFactory Factory;
    protected readonly HttpClient Client;
    protected readonly IServiceScope Scope;
    protected readonly ApplicationDbContext DbContext;

    private static Respawner? _respawner;
    private static string? _connectionString;

    protected BaseIntegrationTest(IntegrationTestWebAppFactory factory)
    {
        Factory = factory;
        Client = factory.CreateClient();
        Scope = factory.Services.CreateScope();
        DbContext = Scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
    }

    // ═══════════════════════════════════════════════════════════════
    // AUTHENTICATION HELPERS
    // ═══════════════════════════════════════════════════════════════

    /// <summary>
    /// Configure client to authenticate as a specific user
    /// </summary>
    protected void AuthenticateAs(
        Guid userId,
        string email = "test@example.com",
        string[]? roles = null,
        string[]? permissions = null)
    {
        Client.DefaultRequestHeaders.Add(TestAuthHandler.TestUserIdHeader, userId.ToString());
        Client.DefaultRequestHeaders.Add(TestAuthHandler.TestUserEmailHeader, email);

        if (roles?.Length > 0)
        {
            Client.DefaultRequestHeaders.Add(
                TestAuthHandler.TestUserRolesHeader,
                string.Join(",", roles));
        }

        if (permissions?.Length > 0)
        {
            Client.DefaultRequestHeaders.Add(
                TestAuthHandler.TestUserPermissionsHeader,
                string.Join(",", permissions));
        }
    }

    /// <summary>
    /// Configure client to authenticate as admin
    /// </summary>
    protected void AuthenticateAsAdmin()
    {
        AuthenticateAs(
            userId: Guid.NewGuid(),
            email: "admin@example.com",
            roles: new[] { "Admin" },
            permissions: new[] { "users:read", "users:write", "users:delete" });
    }

    /// <summary>
    /// Remove authentication headers
    /// </summary>
    protected void RemoveAuthentication()
    {
        Client.DefaultRequestHeaders.Remove(TestAuthHandler.TestUserIdHeader);
        Client.DefaultRequestHeaders.Remove(TestAuthHandler.TestUserEmailHeader);
        Client.DefaultRequestHeaders.Remove(TestAuthHandler.TestUserRolesHeader);
        Client.DefaultRequestHeaders.Remove(TestAuthHandler.TestUserPermissionsHeader);
    }

    // ═══════════════════════════════════════════════════════════════
    // HTTP HELPERS
    // ═══════════════════════════════════════════════════════════════

    protected async Task<HttpResponseMessage> GetAsync(string url)
    {
        return await Client.GetAsync(url);
    }

    protected async Task<T?> GetAsync<T>(string url)
    {
        var response = await Client.GetAsync(url);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<T>();
    }

    protected async Task<HttpResponseMessage> PostAsync<T>(string url, T content)
    {
        return await Client.PostAsJsonAsync(url, content);
    }

    protected async Task<TResponse?> PostAsync<TRequest, TResponse>(string url, TRequest content)
    {
        var response = await Client.PostAsJsonAsync(url, content);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<TResponse>();
    }

    protected async Task<HttpResponseMessage> PutAsync<T>(string url, T content)
    {
        return await Client.PutAsJsonAsync(url, content);
    }

    protected async Task<HttpResponseMessage> DeleteAsync(string url)
    {
        return await Client.DeleteAsync(url);
    }

    // ═══════════════════════════════════════════════════════════════
    // DATABASE HELPERS
    // ═══════════════════════════════════════════════════════════════

    /// <summary>
    /// Add entity directly to database for test setup
    /// </summary>
    protected async Task AddAsync<TEntity>(TEntity entity) where TEntity : class
    {
        DbContext.Set<TEntity>().Add(entity);
        await DbContext.SaveChangesAsync();
    }

    /// <summary>
    /// Get entity from database
    /// </summary>
    protected async Task<TEntity?> FindAsync<TEntity>(Guid id) where TEntity : class
    {
        return await DbContext.Set<TEntity>().FindAsync(id);
    }

    /// <summary>
    /// Execute raw SQL for test setup
    /// </summary>
    protected async Task ExecuteSqlAsync(string sql, object? parameters = null)
    {
        await DbContext.Database.ExecuteSqlRawAsync(sql);
    }

    // ═══════════════════════════════════════════════════════════════
    // LIFECYCLE
    // ═══════════════════════════════════════════════════════════════

    public async Task InitializeAsync()
    {
        // Initialize Respawner once
        if (_respawner is null)
        {
            _connectionString = DbContext.Database.GetConnectionString();
            
            await using var connection = new NpgsqlConnection(_connectionString);
            await connection.OpenAsync();

            _respawner = await Respawner.CreateAsync(connection, new RespawnerOptions
            {
                DbAdapter = DbAdapter.Postgres,
                SchemasToInclude = new[] { "public" },
                TablesToIgnore = new Respawn.Graph.Table[] 
                { 
                    "__EFMigrationsHistory"  // Don't reset migrations table
                }
            });
        }

        // Reset database before each test
        await using var conn = new NpgsqlConnection(_connectionString);
        await conn.OpenAsync();
        await _respawner.ResetAsync(conn);
    }

    public Task DisposeAsync()
    {
        Scope.Dispose();
        return Task.CompletedTask;
    }
}

/// <summary>
/// Collection definition for integration tests
/// Ensures tests share the same WebApplicationFactory
/// </summary>
[CollectionDefinition("Integration")]
public class IntegrationTestCollection : ICollectionFixture<IntegrationTestWebAppFactory>
{
}

Template: Integration Tests

csharp
// tests/{name}.Api.IntegrationTests/{Feature}/Create{Entity}Tests.cs
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using {name}.Api.IntegrationTests.Infrastructure;
using {name}.application.{feature}.Create{Entity};

namespace {name}.Api.IntegrationTests.{Feature};

public class Create{Entity}Tests : BaseIntegrationTest
{
    public Create{Entity}Tests(IntegrationTestWebAppFactory factory) 
        : base(factory)
    {
    }

    // ═══════════════════════════════════════════════════════════════
    // SUCCESS TESTS
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public async Task Create_Should_ReturnCreated_When_ValidRequest()
    {
        // Arrange
        AuthenticateAsAdmin();

        var request = new Create{Entity}Request
        {
            Name = "Test Entity",
            Description = "Test Description",
            OrganizationId = Guid.NewGuid()
        };

        // Act
        var response = await PostAsync("/api/v1/{entities}", request);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Created);

        var entityId = await response.Content.ReadFromJsonAsync<Guid>();
        entityId.Should().NotBeEmpty();

        // Verify in database
        var entity = await FindAsync<Domain.{Aggregate}.{Entity}>(entityId);
        entity.Should().NotBeNull();
        entity!.Name.Should().Be(request.Name);
    }

    [Fact]
    public async Task Create_Should_ReturnLocationHeader_When_Created()
    {
        // Arrange
        AuthenticateAsAdmin();

        var request = new Create{Entity}Request
        {
            Name = "Test Entity",
            Description = "Test Description",
            OrganizationId = Guid.NewGuid()
        };

        // Act
        var response = await PostAsync("/api/v1/{entities}", request);

        // Assert
        response.Headers.Location.Should().NotBeNull();
        response.Headers.Location!.ToString().Should().Contain("/api/v1/{entities}/");
    }

    // ═══════════════════════════════════════════════════════════════
    // VALIDATION TESTS
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public async Task Create_Should_ReturnBadRequest_When_NameIsEmpty()
    {
        // Arrange
        AuthenticateAsAdmin();

        var request = new Create{Entity}Request
        {
            Name = string.Empty,
            Description = "Test Description",
            OrganizationId = Guid.NewGuid()
        };

        // Act
        var response = await PostAsync("/api/v1/{entities}", request);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    }

    [Fact]
    public async Task Create_Should_ReturnBadRequest_When_NameTooLong()
    {
        // Arrange
        AuthenticateAsAdmin();

        var request = new Create{Entity}Request
        {
            Name = new string('a', 101),  // Exceeds 100 char limit
            Description = "Test Description",
            OrganizationId = Guid.NewGuid()
        };

        // Act
        var response = await PostAsync("/api/v1/{entities}", request);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    }

    // ═══════════════════════════════════════════════════════════════
    // AUTHENTICATION TESTS
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public async Task Create_Should_ReturnUnauthorized_When_NotAuthenticated()
    {
        // Arrange
        RemoveAuthentication();

        var request = new Create{Entity}Request
        {
            Name = "Test Entity",
            Description = "Test Description",
            OrganizationId = Guid.NewGuid()
        };

        // Act
        var response = await PostAsync("/api/v1/{entities}", request);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
    }

    // ═══════════════════════════════════════════════════════════════
    // AUTHORIZATION TESTS
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public async Task Create_Should_ReturnForbidden_When_NoWritePermission()
    {
        // Arrange
        AuthenticateAs(
            userId: Guid.NewGuid(),
            permissions: new[] { "entities:read" });  // Only read permission

        var request = new Create{Entity}Request
        {
            Name = "Test Entity",
            Description = "Test Description",
            OrganizationId = Guid.NewGuid()
        };

        // Act
        var response = await PostAsync("/api/v1/{entities}", request);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
    }

    // ═══════════════════════════════════════════════════════════════
    // CONFLICT TESTS
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public async Task Create_Should_ReturnConflict_When_NameAlreadyExists()
    {
        // Arrange
        AuthenticateAsAdmin();

        // Create first entity
        var firstRequest = new Create{Entity}Request
        {
            Name = "Duplicate Name",
            Description = "First entity",
            OrganizationId = Guid.NewGuid()
        };
        await PostAsync("/api/v1/{entities}", firstRequest);

        // Try to create second entity with same name
        var secondRequest = new Create{Entity}Request
        {
            Name = "Duplicate Name",  // Same name
            Description = "Second entity",
            OrganizationId = Guid.NewGuid()
        };

        // Act
        var response = await PostAsync("/api/v1/{entities}", secondRequest);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Conflict);
    }
}

Template: Get Entity Tests

csharp
// tests/{name}.Api.IntegrationTests/{Feature}/Get{Entity}Tests.cs
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using {name}.Api.IntegrationTests.Infrastructure;
using {name}.application.{feature}.Get{Entity}ById;

namespace {name}.Api.IntegrationTests.{Feature};

public class Get{Entity}Tests : BaseIntegrationTest
{
    public Get{Entity}Tests(IntegrationTestWebAppFactory factory) 
        : base(factory)
    {
    }

    [Fact]
    public async Task GetById_Should_ReturnEntity_When_Exists()
    {
        // Arrange
        AuthenticateAsAdmin();

        var entityId = await CreateTestEntity();

        // Act
        var response = await GetAsync($"/api/v1/{entities}/{entityId}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var result = await response.Content.ReadFromJsonAsync<{Entity}Response>();
        result.Should().NotBeNull();
        result!.Id.Should().Be(entityId);
    }

    [Fact]
    public async Task GetById_Should_ReturnNotFound_When_NotExists()
    {
        // Arrange
        AuthenticateAsAdmin();
        var nonExistentId = Guid.NewGuid();

        // Act
        var response = await GetAsync($"/api/v1/{entities}/{nonExistentId}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.NotFound);
    }

    [Fact]
    public async Task GetAll_Should_ReturnPaginatedList()
    {
        // Arrange
        AuthenticateAsAdmin();

        // Create multiple entities
        await CreateTestEntity("Entity 1");
        await CreateTestEntity("Entity 2");
        await CreateTestEntity("Entity 3");

        // Act
        var response = await GetAsync("/api/v1/{entities}?pageNumber=1&pageSize=10");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var result = await response.Content.ReadFromJsonAsync<PagedListResponse>();
        result.Should().NotBeNull();
        result!.Items.Should().HaveCount(3);
        result.TotalCount.Should().Be(3);
    }

    // ═══════════════════════════════════════════════════════════════
    // HELPER METHODS
    // ═══════════════════════════════════════════════════════════════

    private async Task<Guid> CreateTestEntity(string name = "Test Entity")
    {
        var request = new
        {
            Name = name,
            Description = "Test Description",
            OrganizationId = Guid.NewGuid()
        };

        var response = await PostAsync("/api/v1/{entities}", request);
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<Guid>();
    }
}

public class PagedListResponse
{
    public List<{Entity}Response> Items { get; set; } = new();
    public int PageNumber { get; set; }
    public int PageSize { get; set; }
    public int TotalCount { get; set; }
}

Template: Test Utilities

csharp
// tests/{name}.Api.IntegrationTests/Infrastructure/TestDataSeeder.cs
using {name}.infrastructure;

namespace {name}.Api.IntegrationTests.Infrastructure;

public static class TestDataSeeder
{
    public static async Task SeedOrganizationAsync(
        ApplicationDbContext context,
        Guid organizationId,
        string name = "Test Organization")
    {
        var organization = new
        {
            Id = organizationId,
            Name = name,
            CreatedAtUtc = DateTime.UtcNow
        };

        await context.Database.ExecuteSqlRawAsync(
            @"INSERT INTO organizations (id, name, created_at_utc) 
              VALUES ({0}, {1}, {2})",
            organizationId,
            name,
            DateTime.UtcNow);
    }

    public static async Task SeedUserAsync(
        ApplicationDbContext context,
        Guid userId,
        string email,
        Guid organizationId)
    {
        await context.Database.ExecuteSqlRawAsync(
            @"INSERT INTO users (id, email, name, organization_id, is_active, created_at_utc) 
              VALUES ({0}, {1}, {2}, {3}, true, {4})",
            userId,
            email,
            "Test User",
            organizationId,
            DateTime.UtcNow);
    }
}

Running Tests

bash
# Run all integration tests
dotnet test tests/{name}.Api.IntegrationTests

# Run with verbose output
dotnet test tests/{name}.Api.IntegrationTests -v n

# Run specific test class
dotnet test tests/{name}.Api.IntegrationTests --filter "FullyQualifiedName~Create{Entity}Tests"

# Run with code coverage
dotnet test tests/{name}.Api.IntegrationTests --collect:"XPlat Code Coverage"

Critical Rules

  1. Fresh database per test - Use Respawn to reset
  2. Isolated tests - No shared state between tests
  3. Real database - Use Testcontainers, not in-memory
  4. Test through HTTP - Use HttpClient, not direct calls
  5. Authentication helpers - Easy user impersonation
  6. Meaningful assertions - Verify both response and database
  7. Test all status codes - 200, 400, 401, 403, 404, 409
  8. Parallel execution - Design for concurrent tests
  9. Clean up after tests - Respawn handles this
  10. CI/CD compatible - Docker required for Testcontainers

Anti-Patterns to Avoid

csharp
// ❌ WRONG: Sharing state between tests
private static Guid _sharedEntityId;  // Tests will interfere!

// ✅ CORRECT: Create data per test
[Fact]
public async Task Test1()
{
    var entityId = await CreateTestEntity();
}

// ❌ WRONG: Not resetting database
// Tests depend on order and previous test data

// ✅ CORRECT: Reset before each test (handled by BaseIntegrationTest)
public async Task InitializeAsync()
{
    await _respawner.ResetAsync(connection);
}

// ❌ WRONG: Using in-memory database
services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("test"));
// In-memory doesn't support all EF Core features

// ✅ CORRECT: Use real PostgreSQL with Testcontainers
private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
    .WithImage("postgres:15-alpine")
    .Build();

Related Skills

  • dotnet-unit-testing - Unit tests for handlers
  • dotnet-jwt-authentication - Authentication to test
  • dotnet-permission-authorization - Authorization to test
  • api-controller-generator - Endpoints to test

Frequently asked questions

What does the Dotnet Integration Testing AI skill do?

Configures integration tests with WebApplicationFactory and Testcontainers. Provides test database setup, authentication helpers, and utilities for testing API endpoints with real dependencies.

Why use Dotnet Integration Testing on TypingMind?

Because you install it once and use it with any model. Dotnet Integration Testing is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Dotnet Integration Testing in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/ronnythedev/dotnet-clean-architecture-skills/tree/main/skills/22-dotnet-integration-testing. TypingMind reads its SKILL.md and installs it as a skill you can enable per chat.

Which AI models can use Dotnet Integration Testing?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Dotnet Integration Testing?

As many as you like. As long as a model supports skills, you can use Dotnet Integration Testing with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Dotnet Integration Testing AI skill free?

It is published on GitHub by ronnythedev. Check the repository for licensing terms. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇