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:
HashPassword function returns a valid bcrypt hash. You write the most of these./api/auth/register creates a user in the database AND returns a JWT token. Slower than unit tests, but catches more real-world bugs.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).
/ E2E \ ← Few tests, slow, high confidence
/ Integration \ ← Some tests, moderate speed
/ Unit Tests \ ← Many tests, fast, isolated
‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾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.
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
}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:
go test ./...
# Verbose output (see each test name)
go test -v ./...
# Run a specific test
go test -run TestAdd ./...t.Errorf() (fail with message, keep running), t.Fatal() (fail and stop immediately), t.Run() (run a subtest), and t.Skip() (skip this test).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.
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.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")
}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.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.
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
}":memory:".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.Register success — valid name, email, password returns 201 + access token
- 2.Register validation — missing fields return 422 with error details
- 3.Register duplicate — same email twice returns 409 conflict
- 4.Login success — correct credentials return 200 + tokens
- 5.Login wrong password — returns 401 unauthorized
- 6.Login unknown email — returns 401 unauthorized (same error as wrong password for security)
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)
}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:
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
}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.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.
*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).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:
# 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:
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/opReading 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).
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.
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:
# 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-formChallenge: 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.
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:
# 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.tsgrit dev, then run pnpm test:e2e in a separate terminal.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.
// 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.
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.
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.Tand 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
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:
- Create a task with valid data (201)
- List tasks with pagination (200, correct count)
- Get a task by ID (200, correct data)
- Update a task's status (200, updated field)
- Delete a task (200, subsequent GET returns 404)
Challenge: Final Challenge: Vitest Component Tests
Write 2 Vitest component tests for the Task resource:
- Test that the TaskForm renders all fields (title, description, status, priority, due date)
- Test that the TaskList renders task items with correct titles and status badges
Challenge: Final Challenge: Playwright E2E Test
Write 1 Playwright end-to-end test for the complete task flow:
- Log in as a test user
- Navigate to the tasks page
- Create a new task (fill the form, submit)
- Verify the task appears in the list
- Edit the task's status to "completed"
- Delete the task and verify it's gone
Enjoying the course?
Help us grow — star us on GitHub, subscribe on YouTube, and follow on LinkedIn.