Previous article: ASP.NET Core Docker Kubernetes Deployment Guide | Cloud-Native DevOps (Part-32 of 40)
1. Complete Project Structure & Configuration
Solution Structure
src/
├── MyApp.API/
│ ├── Controllers/
│ ├── Program.cs
│ ├── MyApp.API.csproj
│ └── appsettings.json
├── MyApp.Core/
│ ├── Entities/
│ ├── Interfaces/
│ └── MyApp.Core.csproj
├── MyApp.Infrastructure/
│ ├── Data/
│ ├── Repositories/
│ └── MyApp.Infrastructure.csproj
├── MyApp.Tests.Unit/
│ ├── Controllers/
│ ├── Services/
│ └── MyApp.Tests.Unit.csproj
└── MyApp.Tests.Integration/
├── API/
├── Database/
└── MyApp.Tests.Integration.csproj
Key Configuration Files
Directory.Build .props (for consistent build configuration):
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<CodeAnalysisRuleSet>../stylecop.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
API Project File (MyApp.API.csproj)
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyApp.Infrastructure\MyApp.Infrastructure.csproj" />
</ItemGroup>
</Project>
2. Docker Configuration
Multi-stage Dockerfile
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy project files
COPY ["src/MyApp.API/MyApp.API.csproj", "src/MyApp.API/"]
COPY ["src/MyApp.Core/MyApp.Core.csproj", "src/MyApp.Core/"]
COPY ["src/MyApp.Infrastructure/MyApp.Infrastructure.csproj", "src/MyApp.Infrastructure/"]
COPY ["src/MyApp.Tests.Unit/MyApp.Tests.Unit.csproj", "src/MyApp.Tests.Unit/"]
COPY ["src/MyApp.Tests.Integration/MyApp.Tests.Integration.csproj", "src/MyApp.Tests.Integration/"]
# Restore dependencies
RUN dotnet restore "src/MyApp.API/MyApp.API.csproj"
RUN dotnet restore "src/MyApp.Tests.Unit/MyApp.Tests.Unit.csproj"
RUN dotnet restore "src/MyApp.Tests.Integration/MyApp.Tests.Integration.csproj"
# Copy everything else
COPY . .
# Build and publish
WORKDIR "/src/src/MyApp.API"
RUN dotnet build "MyApp.API.csproj" -c Release -o /app/build
RUN dotnet publish "MyApp.API.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Test stage
FROM build AS test
WORKDIR "/src/src/MyApp.Tests.Unit"
RUN dotnet test --logger "trx;LogFileName=test-results.trx" --collect:"XPlat Code Coverage"
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
# Install curl for health checks
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Create a non-root user
RUN groupadd -r appuser && useradd -r -g appuser appuser
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
EXPOSE 8081
COPY --from=build /app/publish .
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "MyApp.API.dll"]
docker-compose.yml for local development:
version: '3.8'
services:
myapp.api:
build:
context: .
dockerfile: src/MyApp.API/Dockerfile
target: final
ports:
- "8080:8080"
- "8081:8081"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=Server=sqlserver;Database=MyAppDb;User Id=sa;Password=YourPassword123!;TrustServerCertificate=true;
depends_on:
sqlserver:
condition: service_healthy
networks:
- myapp-network
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
SA_PASSWORD: "YourPassword123!"
ACCEPT_EULA: "Y"
MSSQL_PID: "Express"
ports:
- "1433:1433"
networks:
- myapp-network
healthcheck:
test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "YourPassword123!" -Q "SELECT 1" || exit 1
interval: 10s
retries: 10
start_period: 30s
timeout: 3s
seq:
image: datalust/seq:latest
environment:
- ACCEPT_EULA=Y
ports:
- "5341:5341"
- "8081:80"
networks:
- myapp-network
networks:
myapp-network:
driver: bridge
3. Comprehensive Test Suite
Unit Test Example (MyApp.Tests.Unit/Services/UserServiceTests.cs):
using Moq;
using FluentAssertions;
using MyApp.Core.Entities;
using MyApp.Core.Interfaces;
using MyApp.Infrastructure.Services;
namespace MyApp.Tests.Unit.Services;
public class UserServiceTests
{
private readonly Mock<IUserRepository> _mockUserRepo;
private readonly UserService _userService;
public UserServiceTests()
{
_mockUserRepo = new Mock<IUserRepository>();
_userService = new UserService(_mockUserRepo.Object);
}
[Fact]
public async Task GetUserById_WithValidId_ReturnsUser()
{
// Arrange
var expectedUser = new User { Id = 1, Email = "[email protected]", Name = "Test User" };
_mockUserRepo.Setup(repo => repo.GetByIdAsync(1)).ReturnsAsync(expectedUser);
// Act
var result = await _userService.GetUserById(1);
// Assert
result.Should().NotBeNull();
result.Email.Should().Be("[email protected]");
result.Name.Should().Be("Test User");
_mockUserRepo.Verify(repo => repo.GetByIdAsync(1), Times.Once);
}
[Theory]
[InlineData("")]
[InlineData(null)]
[InlineData("invalid-email")]
public async Task CreateUser_WithInvalidEmail_ThrowsArgumentException(string invalidEmail)
{
// Arrange
var user = new User { Email = invalidEmail, Name = "Test User" };
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => _userService.CreateUser(user));
}
}
Integration Test Example (MyApp.Tests.Integration/API/UsersControllerIntegrationTests.cs)
using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using MyApp.API;
using MyApp.Core.Entities;
namespace MyApp.Tests.Integration.API;
public class UsersControllerIntegrationTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
private readonly string _connectionString;
public UsersControllerIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace real database with test database
services.AddScoped<IConnectionFactory, TestConnectionFactory>();
});
});
_client = _factory.CreateClient();
_connectionString = "TestConnectionString";
}
[Fact]
public async Task GetUsers_ReturnsSuccessStatusCode()
{
// Act
var response = await _client.GetAsync("/api/users");
// Assert
response.EnsureSuccessStatusCode();
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task CreateUser_WithValidData_ReturnsCreatedUser()
{
// Arrange
var user = new { Name = "Integration Test User", Email = "[email protected]" };
var content = new StringContent(JsonSerializer.Serialize(user), Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/users", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var responseContent = await response.Content.ReadAsStringAsync();
var createdUser = JsonSerializer.Deserialize<User>(responseContent, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
createdUser.Should().NotBeNull();
createdUser.Name.Should().Be("Integration Test User");
}
public async Task InitializeAsync()
{
await ResetTestDatabase();
}
public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}
private async Task ResetTestDatabase()
{
// Implementation to reset test database
await Task.CompletedTask;
}
}

Join the conversation! Your thoughts help the community grow.