Get a quote

Go API Testing in Production SaaS: The Patterns That Actually Catch Real Bugs

Unit tests with mocked dependencies catch almost none of the bugs that actually break SaaS backends in production. The bugs that cause incidents are integration failures: wrong SQL queries, unexpected database states, mismatched serialization between services. Testing strategies that find these bugs look very different from textbook unit testing.

Unit tests with mocked dependencies catch almost none of the bugs that actually break SaaS backends in production. The bugs that cause incidents are integration failures: wrong SQL queries, unexpected database states, mismatched serialization between services, race conditions that only appear under concurrent load.

Testing strategies that find these bugs look very different from textbook unit testing. This is the testing approach Voxire uses for RTYLR, serving restaurant and retail operations across Lebanon and the Gulf, where a bug in the order processing path is not a abstract test failure, it is a restaurant unable to take orders during a dinner rush.

Why mock-heavy unit tests miss production bugs

A typical mock-heavy test for an order confirmation endpoint:

func TestConfirmOrder_Success(t *testing.T) {
    mockDB := &MockOrderRepository{}
    mockDB.On("GetOrder", orderID).Return(pendingOrder, nil)
    mockDB.On("UpdateStatus", orderID, "confirmed").Return(nil)
    mockQueue := &MockPublisher{}
    mockQueue.On("Publish", mock.Anything).Return(nil)

    svc := NewOrderService(mockDB, mockQueue)
    err := svc.ConfirmOrder(ctx, orderID)
    assert.NoError(t, err)
}

This test passes. It would also pass if your SQL query had a typo, if the database constraint prevented the status transition, if the JSONB serialization of the order payload was incorrect, or if concurrent confirmations of the same order caused a race condition.

The mock perfectly implements the interface contract. But the interface contract is not where the bugs are. The bugs are in the implementation of the contract.

Integration testing with a real database

The alternative is testing against a real PostgreSQL instance, spun up in Docker for each test run:

func TestMain(m *testing.M) {
    pool, err := dockertest.NewPool("")
    if err != nil {
        log.Fatalf("Could not connect to docker: %s", err)
    }
    resource, err := pool.Run("postgres", "16", []string{
        "POSTGRES_PASSWORD=test",
        "POSTGRES_DB=testdb",
    })
    if err != nil {
        log.Fatalf("Could not start postgres: %s", err)
    }

    if err := pool.Retry(func() error {
        db, err := sql.Open("pgx", fmt.Sprintf("postgres://postgres:test@localhost:%s/testdb", resource.GetPort("5432/tcp")))
        if err != nil {
            return err
        }
        return db.Ping()
    }); err != nil {
        log.Fatalf("Could not connect to postgres: %s", err)
    }

    testDB = setupSchema()
    code := m.Run()
    pool.Purge(resource)
    os.Exit(code)
}

This test runs your actual SQL migrations against a real database. Any schema error, missing index, or constraint violation surfaces immediately during test execution, not during production deployment.

Test isolation: transactions, not truncation

The common approach for isolating integration tests is to truncate all tables before each test. This works but is slow, especially as the number of tables grows.

A faster approach: wrap each test in a transaction and roll it back at the end.

func withTestTx(t *testing.T, fn func(tx *sql.Tx)) {
    t.Helper()
    tx, err := testDB.Begin()
    if err != nil {
        t.Fatalf("begin tx: %v", err)
    }
    defer tx.Rollback()
    fn(tx)
}

func TestConfirmOrder_ConcurrentRequests(t *testing.T) {
    withTestTx(t, func(tx *sql.Tx) {
        order := insertTestOrder(tx, "pending")
        svc := NewOrderService(tx, testPublisher)

        // First confirmation should succeed
        err := svc.ConfirmOrder(ctx, order.ID)
        assert.NoError(t, err)

        // Second concurrent confirmation should fail
        err = svc.ConfirmOrder(ctx, order.ID)
        assert.ErrorIs(t, err, ErrAlreadyConfirmed)
    })
}

The rollback at the end leaves the database in a clean state for the next test without the overhead of truncating tables. This approach also works with savepoints for testing nested transaction behavior.

Table-driven tests for API endpoint coverage

For HTTP handler tests, table-driven tests cover edge cases systematically:

func TestUpdateMenuItemPrice(t *testing.T) {
    cases := []struct {
        name       string
        body       string
        statusCode int
        wantPrice  string
    }{
        {
            name:       "valid price update",
            body:       `{"price": "12.50"}`,
            statusCode: 200,
            wantPrice:  "12.50",
        },
        {
            name:       "negative price rejected",
            body:       `{"price": "-5.00"}`,
            statusCode: 400,
        },
        {
            name:       "zero price rejected",
            body:       `{"price": "0"}`,
            statusCode: 400,
        },
        {
            name:       "non-numeric price rejected",
            body:       `{"price": "free"}`,
            statusCode: 400,
        },
    }

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            withTestTx(t, func(tx *sql.Tx) {
                item := insertTestMenuItem(tx)
                req := httptest.NewRequest(http.MethodPatch,
                    "/api/v1/menu-items/"+item.ID.String(),
                    strings.NewReader(tc.body))
                req.Header.Set("Content-Type", "application/json")
                w := httptest.NewRecorder()
                handler := NewMenuHandler(tx)
                handler.UpdatePrice(w, req)
                assert.Equal(t, tc.statusCode, w.Code)
                if tc.wantPrice != "" {
                    var resp struct{ Price string }
                    json.Unmarshal(w.Body.Bytes(), &resp)
                    assert.Equal(t, tc.wantPrice, resp.Price)
                }
            })
        })
    }
}

Testing multi-tenant isolation

In a multi-tenant system, the most critical test category is isolation: ensure that tenant A's requests cannot read or modify tenant B's data.

func TestMenuItemQuery_TenantIsolation(t *testing.T) {
    withTestTx(t, func(tx *sql.Tx) {
        tenantA := insertTestTenant(tx)
        tenantB := insertTestTenant(tx)
        itemA := insertTestMenuItem(tx, tenantA.ID)
        _ = insertTestMenuItem(tx, tenantB.ID)

        repo := NewMenuRepository(tx)
        items, err := repo.ListMenuItems(ctx, tenantA.ID)
        assert.NoError(t, err)
        assert.Len(t, items, 1)
        assert.Equal(t, itemA.ID, items[0].ID)

        // Verify tenant B's item is not accessible
        _, err = repo.GetMenuItem(ctx, tenantA.ID, tenantB.ID)
        assert.ErrorIs(t, err, sql.ErrNoRows)
    })
}

This class of test found three real bugs in RTYLR's codebase where a tenant_id filter was missing from a query that joined across two tables.

Testing sync scenarios

For RTYLR's offline sync system, integration tests simulate terminal reconnection scenarios:

func TestSync_OfflineOrdersApplied(t *testing.T) {
    withTestTx(t, func(tx *sql.Tx) {
        terminal := insertTestTerminal(tx)
        initialSeq := getServerSeq(tx)

        offlineOrders := []Event{
            makeOrderCreatedEvent(terminal.ID),
            makeOrderCreatedEvent(terminal.ID),
        }

        syncSvc := NewSyncService(tx)
        resp, err := syncSvc.ProcessSync(ctx, SyncRequest{
            TerminalID:    terminal.ID,
            LastServerSeq: initialSeq,
            PendingEvents: offlineOrders,
        })
        assert.NoError(t, err)
        assert.Len(t, resp.NewEvents, 0)

        // Verify offline orders persisted
        orders, _ := listOrdersByTerminal(tx, terminal.ID)
        assert.Len(t, orders, 2)
    })
}

Load and concurrency testing

The bugs that slip through integration tests appear under concurrent load. Two patterns for finding them:

First, run the same handler concurrently in tests and check for data races:

func TestCreateOrder_Concurrent(t *testing.T) {
    // Don't use withTestTx here — concurrent access on a single tx deadlocks
    db := getTestDB()
    location := insertTestLocation(db)
    var wg sync.WaitGroup
    errs := make([]error, 10)
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(idx int) {
            defer wg.Done()
            svc := NewOrderService(db, testPublisher)
            _, errs[idx] = svc.CreateOrder(ctx, location.ID, testOrderItems)
        }(i)
    }
    wg.Wait()
    // All should succeed, none should produce a duplicate
    successCount := 0
    for _, err := range errs {
        if err == nil {
            successCount++
        }
    }
    assert.Equal(t, 10, successCount)
    orders, _ := listOrdersByLocation(db, location.ID)
    assert.Len(t, orders, 10)
}

Second, run Go's race detector on your test suite: go test -race ./.... This catches race conditions that do not manifest as test failures in single-threaded runs but cause crashes or data corruption under load.

Key lessons from RTYLR testing

Three things changed the most bugs caught per engineering hour:

First, shifting from mocked unit tests to integration tests with real databases found more bugs in the first week than the previous six months of mocked testing had found. The friction of setting up Docker for tests was a one-time cost. The ongoing benefit is continuous.

Second, the tenant isolation test suite runs as a mandatory gate in CI. Any query that touches tenant-scoped data without a tenant_id filter is caught before the code reaches production. This rule was established after a production incident where a missing filter caused a cross-tenant data leak.

Third, test fixtures maintained in SQL seed files are better than Go factory functions for complex data scenarios. A SQL fixture that represents a restaurant with five menu items, two staff accounts, and three pending orders is readable and reusable across test cases without Go function call chains.

Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

Not sure where to start?

If you are building Go SaaS backends in Lebanon or MENA and want a testing strategy that finds real bugs before they reach production, Voxire can help you design and implement the right approach for your system's architecture and scale.

https://voxire.com/get-a-quote/

Back to blog
Chat on WhatsApp