Courses/Testing Your Grit App
Standalone Course~30 min14 challenges

Testing Your Grit App: Go, Vitest & Playwright

Untested code is broken code — you just don't know it yet. In this course, you'll learn to test every layer of a Grit application: Go unit tests for your API, component tests for your React frontend, and end-to-end tests that simulate real users clicking through your app. Grit scaffolds test files for you. Your job is to understand them and write more.


Why Testing Matters

Every application has bugs. The question is whether you find them before or after your users do. Testing is how you catch bugs automatically, before they reach production. There are three main types of tests, and they form a pyramid:

Unit Test: Tests a single function or method in isolation. Fast to run, easy to write. Example: testing that a HashPassword function returns a valid bcrypt hash. You write the most of these.
Integration Test: Tests how multiple parts work together. Example: testing that a POST to /api/auth/register creates a user in the database AND returns a JWT token. Slower than unit tests, but catches more real-world bugs.
End-to-End Test (E2E): Tests the entire application as a real user would — opening a browser, clicking buttons, filling forms, and verifying what appears on screen. Slowest to run, but catches the most critical bugs. You write the fewest of these.

The testing pyramid: many unit tests at the base, fewer integration tests in the middle, and a small number of E2E tests at the top. This gives you fast feedback (unit tests run in milliseconds) and high confidence (E2E tests prove the whole system works).

The Testing Pyramid
        /  E2E  \           ← Few tests, slow, high confidence
       / Integration \       ← Some tests, moderate speed
      /   Unit Tests   \     ← Many tests, fast, isolated
     ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
Without tests, every code change is a gamble. You refactor a function and hope nothing broke. You add a feature and manually click through 20 pages to verify. With tests, you run one command and know in seconds whether everything still works.
1

Challenge: Name 3 Things That Can Go Wrong

Think about an application with no tests at all. Name 3 specific things that can go wrong when a developer pushes new code. For each one, explain how a test would have caught the problem before it reached production.

Go Test Basics

Go has testing built into the language. No external framework needed. Test files end with _test.go, and test functions start with Test followed by a capital letter. The go test command finds and runs them automatically.

math.go
package math

func Add(a, b int) int {
    return a + b
}

func IsValidEmail(email string) bool {
    // Must contain @ and at least one dot after @
    atIndex := -1
    for i, ch := range email {
        if ch == '@' {
            atIndex = i
        }
    }
    if atIndex < 1 {
        return false
    }
    domain := email[atIndex+1:]
    for _, ch := range domain {
        if ch == '.' {
            return true
        }
    }
    return false
}
math_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("expected 5, got %d", result)
    }
}

func TestAddNegative(t *testing.T) {
    result := Add(-1, -2)
    if result != -3 {
        t.Errorf("expected -3, got %d", result)
    }
}

Run all tests in a project with:

Terminal
go test ./...

# Verbose output (see each test name)
go test -v ./...

# Run a specific test
go test -run TestAdd ./...
testing.T: Go's test context, passed to every test function. It provides methods to report failures:t.Errorf() (fail with message, keep running), t.Fatal() (fail and stop immediately), t.Run() (run a subtest), and t.Skip() (skip this test).
2

Challenge: Write an Email Validation Test

Write a test function called TestIsValidEmail that tests the IsValidEmail function. Test at least 4 cases: a valid email, an email without @, an email without a dot in the domain, and an empty string. Use t.Errorf for failures.

Testing with testify

Go's built-in testing works, but the assertions are verbose. Writing if result != expected for every check gets tedious. The testify library gives you clean, readable assertions.

testify: The most popular Go testing toolkit. It provides two packages: assert (reports failure but continues the test) and require (reports failure and stops immediately). Useassert when you want to see all failures at once. Use require when the rest of the test depends on this check passing.
user_test.go
package service

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestCreateUser(t *testing.T) {
    user := CreateUser("John", "john@test.com")

    // assert: if this fails, keep running the other checks
    assert.Equal(t, "John", user.Name)
    assert.Equal(t, "john@test.com", user.Email)
    assert.NotEmpty(t, user.ID)
    assert.Equal(t, "USER", user.Role)

    // require: if this fails, stop immediately
    require.NotNil(t, user.CreatedAt)
}

func TestCreateUserValidation(t *testing.T) {
    // Empty name should return an error
    user, err := CreateUserSafe("", "john@test.com")
    assert.Error(t, err)
    assert.Nil(t, user)
    assert.Contains(t, err.Error(), "name is required")
}
Use require for setup steps (database connection, creating prerequisite data). If setup fails, there's no point running the rest of the test. Use assert for the actual checks so you see all failures at once.
3

Challenge: Test Blog Post Creation

Write a test using assert.Equal and assert.NotEmpty for a blog post creation function. Check that the title, slug (auto-generated from title), author_id, and created_at fields are set correctly. The slug for "My First Post" should be "my-first-post".

Grit's Scaffolded Tests

When you run grit new myapp, Grit scaffolds test files automatically. You don't start from scratch. Here's what you get:

  • auth_test.go — 6 tests covering registration (success, validation, duplicate email), login (success, wrong password, unknown email)
  • user_test.go — 4 tests covering auth guard (unauthenticated access blocked), admin list users, user not found (404), get user by ID
  • bench_test.go — benchmarks for health check, auth login, and auth register endpoints

All scaffolded tests use SQLite in-memory databases. This means no Docker, no PostgreSQL, no setup — tests run instantly on any machine.

test_helpers.go
package handler_test

import (
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
    "github.com/stretchr/testify/require"
)

func setupTestDB(t *testing.T) *gorm.DB {
    db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
    require.NoError(t, err)

    // Auto-migrate all models
    db.AutoMigrate(&models.User{}, &models.Blog{})
    return db
}

func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
    db := setupTestDB(t)
    router := gin.New()
    // Register routes with test database
    return router, db
}
In-memory Database: A database that exists only in RAM, not on disk. Created when the test starts, destroyed when the test ends. Extremely fast because there's no disk I/O. Each test gets a fresh, empty database — no leftover data from previous tests. SQLite supports this with the connection string ":memory:".
4

Challenge: Find and Run the Scaffolded Tests

Navigate to apps/api/ in your Grit project. Find the test files (they end with _test.go). Run go test -v ./... and observe the output. How many tests pass? How many are there total?

Testing Auth Endpoints

Authentication is the most critical part of your API. If auth is broken, everything is broken. Grit's scaffolded auth_test.go tests 6 scenarios:

  1. 1.Register success — valid name, email, password returns 201 + access token
  2. 2.Register validation — missing fields return 422 with error details
  3. 3.Register duplicate — same email twice returns 409 conflict
  4. 4.Login success — correct credentials return 200 + tokens
  5. 5.Login wrong password — returns 401 unauthorized
  6. 6.Login unknown email — returns 401 unauthorized (same error as wrong password for security)
auth_test.go
func TestRegisterSuccess(t *testing.T) {
    router, _ := setupTestRouter(t)

    body := map[string]string{
        "name":     "John Doe",
        "email":    "john@test.com",
        "password": "securePass123",
    }
    // POST /api/auth/register with JSON body
    // Assert: status 201
    // Assert: response has "access_token" field
    // Assert: response has "user" object with name and email
}

func TestLoginWrongPassword(t *testing.T) {
    router, _ := setupTestRouter(t)

    // First: register a user
    // Then: attempt login with wrong password
    // Assert: status 401
    // Assert: response has "error" field
}

func TestRegisterDuplicateEmail(t *testing.T) {
    router, _ := setupTestRouter(t)

    // Register once: success (201)
    // Register again with same email: conflict (409)
}
Notice that login with a wrong password and login with an unknown email both return 401 with the same generic message. This is intentional — if the error said "email not found" vs"wrong password", an attacker could enumerate valid email addresses.
5

Challenge: Read the Auth Tests

Open auth_test.go in your scaffolded project. Read through all 6 test functions. For each one, write down: (1) what HTTP method and path it calls, (2) what request body it sends, and (3) what status code and response body it asserts.

Testing Generated Resources

After running grit generate resource Product name:string price:float active:bool, you get CRUD endpoints. Every endpoint needs tests. Here's the pattern for testing a generated resource:

product_test.go
func TestCreateProduct(t *testing.T) {
    router, db := setupTestRouter(t)
    token := createTestUser(t, db) // helper: register + get token

    body := map[string]interface{}{
        "name":   "Widget",
        "price":  29.99,
        "active": true,
    }
    // POST /api/products with auth token
    // Assert: status 201
    // Assert: response "data" has name, price, active
    // Assert: product exists in database
}

func TestListProducts(t *testing.T) {
    router, db := setupTestRouter(t)
    token := createTestUser(t, db)

    // Create 5 products
    for i := 0; i < 5; i++ {
        // POST /api/products
    }

    // GET /api/products
    // Assert: status 200
    // Assert: response "data" has 5 items
    // Assert: response "meta" has total, page, page_size
}

func TestGetProduct(t *testing.T) {
    // Create a product, then GET /api/products/:id
    // Assert: 200, correct product returned
}

func TestUpdateProduct(t *testing.T) {
    // Create a product, then PUT /api/products/:id
    // Assert: 200, updated fields match
}

func TestDeleteProduct(t *testing.T) {
    // Create a product, then DELETE /api/products/:id
    // Assert: 200
    // GET /api/products/:id should now return 404
}
Create a createTestUser helper function that registers a user and returns the JWT token. You'll use it in almost every test. The scaffolded test files already include this helper.
6

Challenge: Write CRUD Tests for a Product

Generate a Product resource with grit generate resource Product name:string price:float active:bool category:string. Then write 5 tests: Create (valid data returns 201), List (5 products with pagination), GetByID (correct product), Update (fields change), Delete (returns 200, subsequent GET returns 404).

Benchmarks

Tests tell you if your code is correct. Benchmarks tell you if your code is fast. Go has built-in benchmarking — functions named BenchmarkXxx that measure how many operations per second your code can handle.

Benchmark: A performance test that measures how fast code runs. The function receives *testing.B and runs the code b.N times. Go automatically adjusts b.N to get a reliable measurement. Results show nanoseconds per operation (ns/op).
bench_test.go
package handler_test

import (
    "testing"
    "golang.org/x/crypto/bcrypt"
)

func BenchmarkHashPassword(b *testing.B) {
    for i := 0; i < b.N; i++ {
        bcrypt.GenerateFromPassword([]byte("password123"), 10)
    }
}

func BenchmarkHealthCheck(b *testing.B) {
    router, _ := setupTestRouter(b)
    for i := 0; i < b.N; i++ {
        // GET /api/health
        // Discard response
    }
}

func BenchmarkAuthLogin(b *testing.B) {
    router, db := setupTestRouter(b)
    // Create a user once (outside the loop)
    createTestUser(b, db)

    b.ResetTimer() // Don't count setup time
    for i := 0; i < b.N; i++ {
        // POST /api/auth/login
    }
}

Run benchmarks with:

Terminal
# Run all benchmarks
go test -bench=. ./...

# Run specific benchmark
go test -bench=BenchmarkHashPassword ./...

# Run benchmarks with memory allocation stats
go test -bench=. -benchmem ./...

Typical output looks like:

Benchmark Output
BenchmarkHealthCheck-8       50000    24350 ns/op    1024 B/op    12 allocs/op
BenchmarkAuthLogin-8          500   2345000 ns/op    8192 B/op    45 allocs/op
BenchmarkHashPassword-8         5 210000000 ns/op      64 B/op     2 allocs/op

Reading benchmark output: 50000 is how many times the function ran.24350 ns/op is nanoseconds per operation (lower is faster). Health check is fast (~24 microseconds), login is moderate (~2.3 milliseconds because of password verification), and bcrypt hashing is intentionally slow (~210 milliseconds).

7

Challenge: Run the Scaffolded Benchmarks

Navigate to your API directory. Run go test -bench=. -benchmem ./.... Look at the results. Which endpoint is fastest? Which is slowest? Why is BenchmarkHashPasswordso much slower than the others? (Hint: it's by design.)

Frontend Testing with Vitest

Your Go API is tested. Now let's test the React frontend. Grit uses Vitest — a fast test runner built on Vite — combined with React Testing Library for rendering components.

Vitest: A fast unit test runner for JavaScript and TypeScript. Like Jest but faster because it uses Vite's transformation pipeline. Supports TypeScript natively, has hot module reloading for tests, and is compatible with Jest's API (describe, it, expect).
React Testing Library (RTL): A library for testing React components from the user's perspective. Instead of testing internal state or props, you test what the user sees: rendered text, form fields, buttons. The core principle: "The more your tests resemble the way your software is used, the more confidence they give you."
__tests__/login-form.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect } from 'vitest'

describe('LoginForm', () => {
  it('shows email and password fields', () => {
    // render(LoginForm component)
    // screen.getByPlaceholderText('Email') should exist
    // screen.getByPlaceholderText('Password') should exist
  })

  it('shows validation error for empty email', () => {
    // render(LoginForm component)
    // Click submit button without filling fields
    // screen.getByText('Email is required') should appear
  })

  it('disables submit button while loading', () => {
    // render(LoginForm with loading=true)
    // Submit button should be disabled
    // Button text should be 'Signing in...'
  })
})

Grit scaffolds test files in apps/web/__tests__/ and apps/admin/__tests__/. Run them with:

Terminal
# Run all frontend tests
pnpm test

# Watch mode (re-runs on file changes)
pnpm test:watch

# Run tests for a specific file
pnpm test login-form
8

Challenge: Find and Run Frontend Tests

Navigate to apps/web/ and apps/admin/. Find the test files in the __tests__ directories. Run pnpm test in each. How many component tests does Grit scaffold? What components are tested?

E2E Testing with Playwright

Unit tests check individual pieces. Integration tests check pieces together. But neither tells you if the whole application works from a user's perspective. That's what Playwright does — it opens a real browser, clicks buttons, fills forms, and verifies results.

Playwright: A browser automation framework for end-to-end testing. It controls Chromium, Firefox, and WebKit browsers programmatically. Tests run headlessly (no visible browser window) in CI, or headed (visible) for debugging. Built by Microsoft, it's the modern replacement for Selenium and Cypress.
e2e/auth.spec.ts
import { test, expect } from '@playwright/test'

test('user can register and see dashboard', async ({ page }) => {
  // 1. Navigate to /register
  // await page.goto('/register')

  // 2. Fill in name, email, password
  // await page.fill('[name="name"]', 'John Doe')
  // await page.fill('[name="email"]', 'john@test.com')
  // await page.fill('[name="password"]', 'securePass123')

  // 3. Click Register button
  // await page.click('button[type="submit"]')

  // 4. Assert: redirected to /dashboard
  // await expect(page).toHaveURL('/dashboard')

  // 5. Assert: welcome message visible
  // await expect(page.locator('h1')).toContainText('Dashboard')
})

test('login with invalid credentials shows error', async ({ page }) => {
  // Navigate to /login
  // Fill wrong credentials
  // Click submit
  // Assert: error message appears
  // Assert: still on /login page
})

Grit scaffolds E2E tests in the e2e/ directory with auth.spec.ts andadmin.spec.ts. Run them with:

Terminal
# Run E2E tests (app must be running)
pnpm test:e2e

# Run with visible browser (for debugging)
pnpm test:e2e -- --headed

# Run a specific test file
pnpm test:e2e auth.spec.ts
E2E tests require the application to be running. Start your API and frontend first with grit dev, then run pnpm test:e2e in a separate terminal.
9

Challenge: Find the Playwright Config

Find the Playwright configuration file in your project (playwright.config.ts). What base URL is it configured to use? What browsers does it test against? Start your app with grit dev, then run pnpm test:e2e. Do the tests pass?

Test-Driven Development (TDD)

So far, we've written tests for existing code. TDD flips this around: you write the test first, then write the code to make it pass. It sounds backwards, but it's one of the most effective ways to write reliable code.

Test-Driven Development (TDD): A development practice where you write a failing test before writing any implementation code. The cycle has three steps: Red (write a test that fails), Green (write the minimum code to make it pass), Refactor (clean up the code while keeping tests green). Repeat for each feature.
TDD Example: CalculateDiscount
// STEP 1 — RED: Write the test first
func TestCalculateDiscount(t *testing.T) {
    // 10% discount on $100 = $90
    result := CalculateDiscount(100.0, 10)
    assert.Equal(t, 90.0, result)

    // 25% discount on $200 = $150
    result = CalculateDiscount(200.0, 25)
    assert.Equal(t, 150.0, result)

    // 0% discount = original price
    result = CalculateDiscount(50.0, 0)
    assert.Equal(t, 50.0, result)

    // 100% discount = free
    result = CalculateDiscount(50.0, 100)
    assert.Equal(t, 0.0, result)
}

// STEP 2 — GREEN: Write the minimum code
func CalculateDiscount(price float64, percent int) float64 {
    return price - (price * float64(percent) / 100)
}

// STEP 3 — REFACTOR: Add edge cases
func TestCalculateDiscountEdgeCases(t *testing.T) {
    // Negative discount should return error or original price
    // Discount over 100% should cap at 100%
    // Negative price should return error
}

The power of TDD: by writing the test first, you are forced to think about the interface before the implementation. What should the function be called? What parameters does it take? What does it return? What edge cases exist? The test answers all these questions before you write a single line of implementation code.

10

Challenge: Practice TDD

Use TDD to build a CalculateShipping function. Write the tests first: orders under $50 cost $5.99 shipping, orders $50-$99 cost $2.99, orders $100+ get free shipping, and international orders always cost $15.99. Then implement the function to make all tests pass.

11

Challenge: TDD for String Utilities

Use TDD to build a Slugify function. Write tests first: "Hello World" becomes"hello-world", "My First Blog Post!" becomes "my-first-blog-post", leading/trailing spaces are trimmed, multiple spaces become a single dash. Then implement the function.

Summary

You've learned the entire testing stack for a Grit application:

  • Go unit tests with testing.T and testify assertions
  • In-memory SQLite for fast, isolated database tests
  • Auth endpoint tests covering registration, login, and error cases
  • CRUD resource tests for generated resources (create, list, get, update, delete)
  • Benchmarks to measure endpoint performance with testing.B
  • Vitest for React component testing with React Testing Library
  • Playwright for end-to-end browser automation tests
  • TDD — write the test first, then the code
12

Challenge: Final Challenge: Go API Tests

Generate a Task resource (grit generate resource Task title:string description:text status:string priority:int due_date:time). Write 5 Go API tests:

  1. Create a task with valid data (201)
  2. List tasks with pagination (200, correct count)
  3. Get a task by ID (200, correct data)
  4. Update a task's status (200, updated field)
  5. Delete a task (200, subsequent GET returns 404)
13

Challenge: Final Challenge: Vitest Component Tests

Write 2 Vitest component tests for the Task resource:

  1. Test that the TaskForm renders all fields (title, description, status, priority, due date)
  2. Test that the TaskList renders task items with correct titles and status badges
14

Challenge: Final Challenge: Playwright E2E Test

Write 1 Playwright end-to-end test for the complete task flow:

  1. Log in as a test user
  2. Navigate to the tasks page
  3. Create a new task (fill the form, submit)
  4. Verify the task appears in the list
  5. Edit the task's status to "completed"
  6. Delete the task and verify it's gone