End-to-end verification of a full-stack CRUD feature means tracing the complete data flow — not just “does it work” but “does each layer behave correctly under all conditions.” A systematic verification covers the happy path, validation failures, authorization failures, concurrency conflicts, and the soft-delete cascade. Integration tests using WebApplicationFactory automate this verification so it runs on every pull request.
End-to-End Data Flow Trace
// ── WebApplicationFactory integration test ────────────────────────────────
public class PostsCrudTests : IClassFixture<BlogAppFactory>
{
private readonly HttpClient _client;
private readonly BlogAppFactory _factory;
public PostsCrudTests(BlogAppFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
[Fact]
public async Task FullCrudLifecycle_HappyPath()
{
// 1. LOGIN — get JWT
var loginRes = await _client.PostAsJsonAsync("/api/auth/login",
new { email = "admin@blogapp.com", password = "Admin!123" });
loginRes.EnsureSuccessStatusCode();
var auth = await loginRes.Content.ReadFromJsonAsync<AuthResponse>();
_client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", auth!.AccessToken);
// 2. CREATE — POST /api/posts → 201 Created
var createRes = await _client.PostAsJsonAsync("/api/posts", new {
title = "Integration Test Post",
slug = "integration-test-post",
body = "This is the test body content with enough words to pass validation.",
status = "draft",
});
Assert.Equal(HttpStatusCode.Created, createRes.StatusCode);
var created = await createRes.Content.ReadFromJsonAsync<PostDto>();
Assert.NotNull(created);
Assert.NotEmpty(createRes.Headers.GetValues("Location").First());
// 3. READ — GET /api/posts/{slug} — not visible yet (draft)
var readPublic = await _client.GetAsync($"/api/posts/{created!.Slug}");
Assert.Equal(HttpStatusCode.NotFound, readPublic.StatusCode);
// 4. UPDATE — PUT with ETag
var etag = Convert.ToBase64String(created.RowVersion);
_client.DefaultRequestHeaders.IfMatch.Add(new EntityTagHeaderValue($"\"{etag}\""));
var updateRes = await _client.PutAsJsonAsync($"/api/posts/{created.Id}", new {
title = "Updated Integration Test Post",
status = "published",
});
Assert.Equal(HttpStatusCode.OK, updateRes.StatusCode);
_client.DefaultRequestHeaders.IfMatch.Clear();
// 5. PUBLISH — post now visible publicly
var readPublicAfter = await _client.GetAsync($"/api/posts/{created.Slug}");
Assert.Equal(HttpStatusCode.OK, readPublicAfter.StatusCode);
// 6. CONCURRENCY — stale ETag → 412
_client.DefaultRequestHeaders.IfMatch.Add(new EntityTagHeaderValue($"\"{etag}\""));
var conflictRes = await _client.PutAsJsonAsync($"/api/posts/{created.Id}", new {
title = "Conflict attempt"
});
Assert.Equal(HttpStatusCode.PreconditionFailed, conflictRes.StatusCode);
_client.DefaultRequestHeaders.IfMatch.Clear();
// 7. DELETE — soft delete → 204
var deleteRes = await _client.DeleteAsync($"/api/posts/{created.Id}");
Assert.Equal(HttpStatusCode.NoContent, deleteRes.StatusCode);
// 8. VERIFY — post is archived, not physically deleted
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var post = await db.Posts.IgnoreQueryFilters().FirstOrDefaultAsync(p => p.Id == created.Id);
Assert.NotNull(post);
Assert.Equal("archived", post!.Status);
Assert.False(post.IsPublished);
}
}
WebApplicationFactory spins up the entire ASP.NET Core application in memory — including the real DI container, middleware pipeline, EF Core, and a test database. Integration tests written this way test the complete vertical slice (routing → controller → service → EF Core → database) with a single test method. Use a separate test database (SQLite in-memory or LocalDB with a unique name per test run) to avoid corrupting the development database during test runs.IAsyncLifetime to initialize and teardown test data, or configure the test database to reset between test class runs. The WebApplicationFactory can be configured with a Scope that wipes the database before each test.CRUD Data Flow Summary
| Operation | Angular | API | EF Core SQL | Response |
|---|---|---|---|---|
| Create | POST form data | Validate → service.Create | INSERT INTO Posts | 201 + Location |
| Read List | GET with query params | service.GetPublished | SELECT with OFFSET/FETCH | 200 + PagedResult |
| Read Detail | GET by slug | service.GetBySlug | SELECT with JOINs | 200 + PostDto |
| Update | PUT + If-Match ETag | Check owner → service.Update | UPDATE WHERE RowVersion=@etag | 200 or 412 |
| Delete | DELETE by id | Check owner → service.SoftDelete | UPDATE Status=’archived’ | 204 |
Common Mistakes
Mistake 1 — Integration tests sharing database state (flaky tests)
❌ Wrong — test A creates data that test B depends on; tests pass when run together but fail in isolation.
✅ Correct — each test class creates its own data; uses a fresh or reset test database per test run.
Mistake 2 — Skipping soft-delete verification (physically deleting instead of archiving)
❌ Wrong — integration test only checks 204 response; soft-delete trigger disabled; rows are physically deleted without noticing.
✅ Correct — step 8: query the database with IgnoreQueryFilters() and verify the row still exists with Status = 'archived'.