Structure of a complete test case
A useful test case has minimum structure: Unique ID, Title, Precondition, Numbered Steps, Input Data, Expected Result, Actual Result and Status. The ID (TC-AUTH-001) allows traceability in bug trackers. The title describes the action in one line ("Successful login with valid credentials"). The precondition lists what must be ready before ("Existing registered user").
The steps must be executable by any QA without extra context. Bad: "Do normal login". Good: "1. Go to /login. 2. Enter registered email. 3. Enter correct password. 4. Click Sign In button". Each step should have verifiable expected result, not just the final.
The expected result must be specific and testable. Bad: "Works fine". Good: "User is redirected to /dashboard, sees their name in header, and welcome toast notification appears". If you can't verify the result mechanically, the case isn't useful for automated regression testing.
Case types: positive, negative and edge cases
For each feature you need three types of cases. Positive verifies happy path works: "Login with correct credentials works". Negative verifies errors are handled well: "Login with wrong password shows clear message without revealing if email exists". Edge cases explore limits: "Login with 320-character email (RFC limit) works; 321 characters rejects".
Boris Beizer's rule: bugs appear at edges. For each feature, identify limits: what happens with empty string? max-length string? negative numbers? past dates? concurrency? Stripe has tests for "card with 3-digit CVV vs 4-digit (Amex)". Those cases catch regressions happy tests never find.
A useful technique: equivalence partitioning. If your "age" field accepts 0-150, you have 4 partitions: <0 (invalid), 0-17 (minor), 18-150 (valid), >150 (invalid). You need one case per partition, not one per number. That reduces test set without losing coverage. Microsoft and Atlassian use this technique to reduce QA suites from 10000 to 500 cases keeping coverage.
Common mistakes when writing test cases
Mistake #1: too-abstract cases. "TC-001: Test login" isn't a case, it's a feature name. Each case must test ONE specific behavior. If your case has "Test login: valid, invalid, password recovery, 2FA", split it into 4 cases. Each case is atomic, fails for a single identifiable reason.
Mistake #2: cases without concrete data. "Enter wrong password" is ambiguous. What password? Same correct email? How many attempts? Better: "Enter email 'test@example.com' (registered) and password 'wrongpass123' (not the correct one)". Specific data makes the case reproducible.
Mistake #3: vague expected result. "Should work" isn't a result, it's a wish. Better: "Status code 200, response body contains JWT token in access_token field, sessionId cookie set with HttpOnly flag". If your QA automation can't assert those results in code, the case isn't testable.
Test cases in BDD (Gherkin) and modern frameworks
The Gherkin format (Given/When/Then) used in Cucumber, SpecFlow and Behave replaces traditional tabular format with natural language. "Given user is logged in, When user clicks logout, Then session cookie is cleared and redirected to /login". More readable for product managers but requires discipline: each step must have real implementation in code.
For modern automation, frameworks like Playwright and Cypress allow coding cases directly: test('login works', async ({ page }) => { await page.goto('/login'); await page.fill('#email', 'test@example.com'); ... }). Advantage: the test case IS executable code, not separate document. Disadvantage: requires dev skills to maintain.
Ideal balance: manual cases in tools like TestRail or Zephyr for exploratory QA and complex testing, automated cases in Playwright/Cypress for critical happy path regression, and property-based testing with fast-check for randomly generated edge cases. Stripe, GitHub and Vercel combine all three levels for full coverage without drowning in maintenance.