Critical user journeys are the highest-value E2E tests — they verify that the most important workflows function correctly from end to end. For the BlogApp, these are: browsing and reading posts, creating and publishing a post (admin), and submitting comments. Each journey exercises the full stack — Angular routing, HTTP calls, ASP.NET Core API, EF Core, and SQL Server — producing the highest confidence that the application works as users expect.
BlogApp E2E User Journeys
// ── Journey 1: Public post browsing ──────────────────────────────────────
describe('Public post browsing', () => {
beforeEach(() => cy.visit('/'));
it('loads the post list and navigates to a post', () => {
cy.intercept('GET', '/api/posts*').as('getPosts');
cy.visit('/');
cy.wait('@getPosts');
cy.getByCy('post-card').should('have.length.gt', 0);
cy.getByCy('post-card').first().within(() => {
cy.getByCy('post-title').invoke('text').as('firstTitle');
});
// Click the first post
cy.getByCy('post-card').first().getByCy('read-more-btn').click();
cy.url().should('match', /\/posts\/.+/);
// Verify the post title matches
cy.get('@firstTitle').then(title => {
cy.get('h1').should('contain', title as string);
});
});
it('filters posts by category', () => {
cy.intercept('GET', '/api/posts?*category=dotnet*').as('filteredPosts');
cy.getByCy('category-link-dotnet').click();
cy.wait('@filteredPosts');
cy.url().should('include', 'category=dotnet');
cy.getByCy('active-category').should('contain', '.NET');
});
it('paginates through posts', () => {
cy.getByCy('next-page-btn').click();
cy.url().should('include', 'page=2');
cy.getByCy('post-card').should('have.length.gt', 0);
});
});
// ── Journey 2: Admin post creation and publishing ─────────────────────────
describe('Admin post creation', () => {
beforeEach(() => cy.loginAsAdmin());
it('creates and publishes a post end-to-end', () => {
const uniqueTitle = `E2E Test Post ${Date.now()}`;
const uniqueSlug = `e2e-test-post-${Date.now()}`;
cy.visit('/admin/posts/new');
cy.getByCy('title-input').type(uniqueTitle);
cy.getByCy('slug-input').should('have.value', uniqueSlug.toLowerCase().replace(/ /g, '-'));
cy.getByCy('body-editor').type(
'This is the body of the test post. It has enough content to pass validation.'
);
// Upload a cover image
cy.getByCy('cover-image-upload').selectFile('cypress/fixtures/test-image.jpg', {
action: 'drag-drop',
});
cy.getByCy('image-preview').should('be.visible');
cy.getByCy('upload-progress').should('not.exist'); // upload complete
// Publish
cy.intercept('POST', '/api/posts').as('createPost');
cy.getByCy('publish-button').click();
cy.wait('@createPost').then(interception => {
expect(interception.response!.statusCode).toBe(201);
});
// Verify redirected to post list
cy.url().should('include', '/admin/posts');
cy.getByCy('success-toast').should('contain', 'Post created');
// Verify post appears in the public list
cy.visit('/');
cy.contains(uniqueTitle).should('be.visible');
});
});
// ── Journey 3: Comment submission ─────────────────────────────────────────
describe('Comment submission', () => {
beforeEach(() => cy.loginAsAdmin());
it('submits a comment and sees it appear without page reload', () => {
// Navigate to a published post
cy.visit('/posts/getting-started-dotnet');
const commentText = `Test comment ${Date.now()}`;
// Submit the comment
cy.getByCy('comment-input').type(commentText);
cy.intercept('POST', '/api/comments').as('submitComment');
cy.getByCy('submit-comment-btn').click();
// Verify optimistic update appears immediately
cy.getByCy('comment-list').should('contain', commentText);
cy.getByCy('comment-pending-indicator').should('be.visible');
// Wait for server confirmation
cy.wait('@submitComment').then(interception => {
expect(interception.response!.statusCode).toBe(201);
});
// Pending indicator gone, comment persisted
cy.getByCy('comment-pending-indicator').should('not.exist');
cy.getByCy('comment-input').should('have.value', ''); // cleared after submit
});
});
Date.now() in test data (post titles, slugs) ensures uniqueness across test runs — a post created in one run doesn’t conflict with the next run’s test. This is the Cypress equivalent of using Guid.NewGuid() in integration tests. The slug generated from the timestamp-based title is unique but not human-readable — this is acceptable for test data since it’s only used to verify the created post appears in the list, not to test slug generation logic.cypress/fixtures/) and custom commands. A fixture file published-post.json can contain a known post’s slug, making tests resilient to data changes: cy.fixture('published-post').then(post => cy.visit(`/posts/${post.slug}`)). Custom commands like cy.createPost() abstract the creation API call, making journeys that need a pre-existing post cleaner and less fragile.Date.now(), crypto.randomUUID()) in test data. Also ensure the test database is reset between CI runs — a weekly Respawn of the E2E test database or a separate test database per CI agent prevents accumulated test data interference.Common Mistakes
Mistake 1 — Hardcoded test data that conflicts across parallel runs
❌ Wrong — cy.getByCy('title-input').type('Test Post'); second parallel agent gets 409 on duplicate slug.
✅ Correct — unique title per run: cy.getByCy('title-input').type(\`Test Post ${Date.now()}\`).
Mistake 2 — Testing too many things in one journey test (hard to diagnose failures)
❌ Wrong — one test covers create, publish, comment, view count, notification — any step failure masks all others.
✅ Correct — one critical flow per test; keep journeys focused on the primary user action and its immediate outcomes.