How to Test Screenshot API Integrations with Go

2026-04-21 | Tags: [screenshot-api, go, golang, testing, tutorial]

How to Test Screenshot API Integrations with Go

Go's standard library provides everything you need to test HTTP integrations reliably: httptest.NewServer for in-process fake servers, table-driven tests with t.Run and t.Parallel, and TestMain for suite-level setup and live API gating. This guide covers all of it — no external mock library required.

Project Structure

screenshot/
├── client.go           # ScreenshotClient implementation
├── client_test.go      # Unit tests (fake HTTP server)
├── integration_test.go # Integration tests (live API, gated)
└── go.mod

go.mod

module github.com/yourorg/screenshot

go 1.22

No external dependencies. The Go standard library handles everything.

client.go (Minimal Implementation)

package screenshot

import (
    "fmt"
    "io"
    "net/http"
    "net/url"
    "strconv"
    "time"
)

type Config struct {
    APIKey   string
    BaseURL  string
    Retries  int
    CacheDir string
}

type Client struct {
    cfg    Config
    http   *http.Client
    cache  map[string][]byte // in-memory cache for tests; swap for disk in production
}

func New(cfg Config) (*Client, error) {
    if cfg.APIKey == "" {
        return nil, fmt.Errorf("screenshot: APIKey is required")
    }
    if cfg.BaseURL == "" {
        cfg.BaseURL = "https://hermesforge.dev"
    }
    if cfg.Retries == 0 {
        cfg.Retries = 3
    }
    return &Client{
        cfg:   cfg,
        http:  &http.Client{Timeout: 30 * time.Second},
        cache: make(map[string][]byte),
    }, nil
}

type CaptureOptions struct {
    Width    int
    Height   int
    FullPage bool
    Format   string
}

var DefaultOptions = CaptureOptions{Width: 1280, Height: 800, Format: "png"}

func (c *Client) Capture(targetURL string, opts CaptureOptions) ([]byte, error) {
    if _, err := url.ParseRequestURI(targetURL); err != nil {
        return nil, fmt.Errorf("screenshot: invalid URL %q: %w", targetURL, err)
    }

    cacheKey := fmt.Sprintf("%s|%d|%d|%v|%s", targetURL, opts.Width, opts.Height, opts.FullPage, opts.Format)
    if cached, ok := c.cache[cacheKey]; ok {
        return cached, nil
    }

    q := url.Values{}
    q.Set("url", targetURL)
    q.Set("width", strconv.Itoa(opts.Width))
    q.Set("height", strconv.Itoa(opts.Height))
    if opts.FullPage {
        q.Set("full_page", "true")
    }
    if opts.Format != "" {
        q.Set("format", opts.Format)
    }

    endpoint := c.cfg.BaseURL + "/api/screenshot?" + q.Encode()

    var (
        data []byte
        err  error
    )
    for attempt := 0; attempt <= c.cfg.Retries; attempt++ {
        data, err = c.doRequest(endpoint)
        if err == nil {
            break
        }
        if isRateLimit(err) && attempt < c.cfg.Retries {
            time.Sleep(time.Duration(1<<attempt) * time.Second)
            continue
        }
        break
    }
    if err != nil {
        return nil, err
    }

    c.cache[cacheKey] = data
    return data, nil
}

func (c *Client) Quota() (map[string]any, error) {
    req, _ := http.NewRequest("GET", c.cfg.BaseURL+"/api/usage", nil)
    req.Header.Set("X-API-Key", c.cfg.APIKey)

    resp, err := c.http.Do(req)
    if err != nil {
        return nil, fmt.Errorf("screenshot: quota request failed: %w", err)
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)
    _ = body
    // Parse JSON in real implementation
    return map[string]any{"calls_this_period": 0, "rate_limit_remaining_today": 200}, nil
}

func (c *Client) doRequest(endpoint string) ([]byte, error) {
    req, err := http.NewRequest("GET", endpoint, nil)
    if err != nil {
        return nil, err
    }
    req.Header.Set("X-API-Key", c.cfg.APIKey)

    resp, err := c.http.Do(req)
    if err != nil {
        return nil, fmt.Errorf("screenshot: network error: %w", err)
    }
    defer resp.Body.Close()

    switch resp.StatusCode {
    case http.StatusOK:
        return io.ReadAll(resp.Body)
    case http.StatusTooManyRequests:
        return nil, &RateLimitError{RetryAfter: resp.Header.Get("Retry-After")}
    case http.StatusUnauthorized, http.StatusForbidden:
        return nil, &AuthError{StatusCode: resp.StatusCode}
    default:
        return nil, fmt.Errorf("screenshot: unexpected status %d", resp.StatusCode)
    }
}

type RateLimitError struct{ RetryAfter string }
func (e *RateLimitError) Error() string { return "screenshot: rate limit exceeded" }

type AuthError struct{ StatusCode int }
func (e *AuthError) Error() string { return fmt.Sprintf("screenshot: auth failed (%d)", e.StatusCode) }

func isRateLimit(err error) bool {
    _, ok := err.(*RateLimitError)
    return ok
}

Unit Tests: client_test.go

package screenshot_test

import (
    "fmt"
    "net/http"
    "net/http/httptest"
    "net/url"
    "strings"
    "testing"

    "github.com/yourorg/screenshot"
)

// pngHeader is the minimal PNG magic bytes for test responses.
var pngHeader = []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")

// -------------------------------------------------------
// Test server helpers
// -------------------------------------------------------

// newServer creates a test server that calls the provided handler.
func newServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
    t.Helper()
    srv := httptest.NewServer(handler)
    t.Cleanup(srv.Close)
    return srv
}

// alwaysOK returns a handler that always responds 200 with minimal PNG bytes.
func alwaysOK() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "image/png")
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write(pngHeader)
    }
}

// respondWith creates a handler that returns the given status and body once, then 200.
func respondSequence(responses []int) http.HandlerFunc {
    i := 0
    return func(w http.ResponseWriter, r *http.Request) {
        code := http.StatusOK
        if i < len(responses) {
            code = responses[i]
            i++
        }
        switch code {
        case http.StatusTooManyRequests:
            w.Header().Set("Retry-After", "0")
            http.Error(w, "rate limit", code)
        case http.StatusUnauthorized:
            http.Error(w, "unauthorized", code)
        default:
            w.Header().Set("Content-Type", "image/png")
            w.WriteHeader(http.StatusOK)
            _, _ = w.Write(pngHeader)
        }
    }
}

// captureClient creates a Client pointed at srv with no retries (for explicit retry tests)
// or retries set to n.
func captureClient(t *testing.T, srv *httptest.Server, retries int) *screenshot.Client {
    t.Helper()
    c, err := screenshot.New(screenshot.Config{
        APIKey:  "test-api-key",
        BaseURL: srv.URL,
        Retries: retries,
    })
    if err != nil {
        t.Fatalf("New() error = %v", err)
    }
    return c
}

// -------------------------------------------------------
// Request inspection helper
// -------------------------------------------------------

type requestCapture struct {
    req *http.Request
}

func capturingServer(t *testing.T, capture *requestCapture) *httptest.Server {
    return newServer(t, func(w http.ResponseWriter, r *http.Request) {
        capture.req = r.Clone(r.Context())
        w.Header().Set("Content-Type", "image/png")
        _, _ = w.Write(pngHeader)
    })
}

// -------------------------------------------------------
// Success path
// -------------------------------------------------------

func TestCapture_HappyPath(t *testing.T) {
    t.Parallel()
    srv := newServer(t, alwaysOK())
    c := captureClient(t, srv, 0)

    data, err := c.Capture("https://example.com", screenshot.DefaultOptions)
    if err != nil {
        t.Fatalf("Capture() error = %v", err)
    }
    if !strings.HasPrefix(string(data), "\x89PNG") {
        t.Error("expected PNG magic bytes in response")
    }
}

func TestCapture_SendsAPIKeyHeader(t *testing.T) {
    t.Parallel()
    var cap requestCapture
    srv := capturingServer(t, &cap)
    c := captureClient(t, srv, 0)

    _, _ = c.Capture("https://example.com", screenshot.DefaultOptions)

    if got := cap.req.Header.Get("X-API-Key"); got != "test-api-key" {
        t.Errorf("X-API-Key = %q, want %q", got, "test-api-key")
    }
}

func TestCapture_SendsURLAsQueryParam(t *testing.T) {
    t.Parallel()
    var cap requestCapture
    srv := capturingServer(t, &cap)
    c := captureClient(t, srv, 0)

    target := "https://example.com/path?q=test"
    _, _ = c.Capture(target, screenshot.DefaultOptions)

    q, _ := url.ParseQuery(cap.req.URL.RawQuery)
    if got := q.Get("url"); got != target {
        t.Errorf("url param = %q, want %q", got, target)
    }
}

func TestCapture_DefaultDimensions(t *testing.T) {
    t.Parallel()
    var cap requestCapture
    srv := capturingServer(t, &cap)
    c := captureClient(t, srv, 0)

    _, _ = c.Capture("https://example.com", screenshot.DefaultOptions)

    q, _ := url.ParseQuery(cap.req.URL.RawQuery)
    if q.Get("width") != "1280" || q.Get("height") != "800" {
        t.Errorf("dimensions = %s×%s, want 1280×800", q.Get("width"), q.Get("height"))
    }
}

func TestCapture_FullPageFlag(t *testing.T) {
    t.Parallel()
    var cap requestCapture
    srv := capturingServer(t, &cap)
    c := captureClient(t, srv, 0)

    _, _ = c.Capture("https://example.com", screenshot.CaptureOptions{
        Width: 1280, Height: 800, FullPage: true,
    })

    q, _ := url.ParseQuery(cap.req.URL.RawQuery)
    if q.Get("full_page") != "true" {
        t.Errorf("full_page = %q, want %q", q.Get("full_page"), "true")
    }
}

func TestCapture_HitsScreenshotEndpoint(t *testing.T) {
    t.Parallel()
    var cap requestCapture
    srv := capturingServer(t, &cap)
    c := captureClient(t, srv, 0)

    _, _ = c.Capture("https://example.com", screenshot.DefaultOptions)

    if !strings.Contains(cap.req.URL.Path, "/api/screenshot") {
        t.Errorf("request path = %q, expected to contain /api/screenshot", cap.req.URL.Path)
    }
}

// -------------------------------------------------------
// Error conditions: table-driven
// -------------------------------------------------------

func TestCapture_RateLimitRetryAndSucceeds(t *testing.T) {
    t.Parallel()
    srv := newServer(t, respondSequence([]int{429, 200}))
    c := captureClient(t, srv, 3)

    data, err := c.Capture("https://example.com", screenshot.DefaultOptions)
    if err != nil {
        t.Fatalf("expected success after retry, got error: %v", err)
    }
    if len(data) == 0 {
        t.Error("expected non-empty response")
    }
}

func TestCapture_RateLimitExhaustsRetries(t *testing.T) {
    t.Parallel()
    // Return 429 more times than max retries
    srv := newServer(t, respondSequence([]int{429, 429, 429, 429}))
    c := captureClient(t, srv, 3)

    _, err := c.Capture("https://example.com", screenshot.DefaultOptions)
    if err == nil {
        t.Fatal("expected error after exhausting retries")
    }
    if _, ok := err.(*screenshot.RateLimitError); !ok {
        t.Errorf("expected *RateLimitError, got %T: %v", err, err)
    }
}

func TestCapture_AuthFailureNoRetry(t *testing.T) {
    t.Parallel()
    callCount := 0
    srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
        callCount++
        http.Error(w, "unauthorized", http.StatusUnauthorized)
    })
    c := captureClient(t, srv, 3)

    _, err := c.Capture("https://example.com", screenshot.DefaultOptions)

    if err == nil {
        t.Fatal("expected auth error")
    }
    if _, ok := err.(*screenshot.AuthError); !ok {
        t.Errorf("expected *AuthError, got %T: %v", err, err)
    }
    if callCount != 1 {
        t.Errorf("expected exactly 1 request (no retry on auth), got %d", callCount)
    }
}

// -------------------------------------------------------
// URL validation: table-driven
// -------------------------------------------------------

func TestCapture_URLValidation(t *testing.T) {
    t.Parallel()

    cases := []struct {
        name    string
        url     string
        wantErr bool
    }{
        {"valid https",       "https://example.com",          false},
        {"valid https path",  "https://github.com/user/repo", false},
        {"not a url",         "not-a-url",                    true},
        {"ftp scheme",        "ftp://example.com",            true},
        {"empty string",      "",                             true},
        {"missing scheme",    "example.com",                  true},
    }

    srv := newServer(t, alwaysOK())

    for _, tc := range cases {
        tc := tc // capture range var
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            c := captureClient(t, srv, 0)
            _, err := c.Capture(tc.url, screenshot.DefaultOptions)
            if (err != nil) != tc.wantErr {
                t.Errorf("Capture(%q) error = %v, wantErr = %v", tc.url, err, tc.wantErr)
            }
        })
    }
}

// -------------------------------------------------------
// Custom dimensions: table-driven
// -------------------------------------------------------

func TestCapture_CustomDimensions(t *testing.T) {
    t.Parallel()

    cases := []struct {
        name   string
        width  int
        height int
    }{
        {"HD",  1920, 1080},
        {"Mobile portrait", 390, 844},
        {"Thumbnail", 400, 300},
    }

    for _, tc := range cases {
        tc := tc
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            var cap requestCapture
            srv := capturingServer(t, &cap)
            c := captureClient(t, srv, 0)

            _, _ = c.Capture("https://example.com", screenshot.CaptureOptions{
                Width:  tc.width,
                Height: tc.height,
            })

            q, _ := url.ParseQuery(cap.req.URL.RawQuery)
            wantW := fmt.Sprintf("%d", tc.width)
            wantH := fmt.Sprintf("%d", tc.height)
            if q.Get("width") != wantW || q.Get("height") != wantH {
                t.Errorf("dimensions = %s×%s, want %s×%s",
                    q.Get("width"), q.Get("height"), wantW, wantH)
            }
        })
    }
}

// -------------------------------------------------------
// Caching
// -------------------------------------------------------

func TestCapture_SecondCallHitsCache(t *testing.T) {
    t.Parallel()
    callCount := 0
    srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
        callCount++
        w.Header().Set("Content-Type", "image/png")
        _, _ = w.Write(pngHeader)
    })
    c := captureClient(t, srv, 0)

    first, _ := c.Capture("https://example.com", screenshot.DefaultOptions)
    second, _ := c.Capture("https://example.com", screenshot.DefaultOptions)

    if string(first) != string(second) {
        t.Error("expected same data from cache")
    }
    if callCount != 1 {
        t.Errorf("expected 1 HTTP request (cache hit on 2nd), got %d", callCount)
    }
}

func TestCapture_DifferentURLsAreCachedSeparately(t *testing.T) {
    t.Parallel()
    callCount := 0
    srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
        callCount++
        w.Header().Set("Content-Type", "image/png")
        _, _ = w.Write(pngHeader)
    })
    c := captureClient(t, srv, 0)

    _, _ = c.Capture("https://example.com", screenshot.DefaultOptions)
    _, _ = c.Capture("https://different.com", screenshot.DefaultOptions)

    if callCount != 2 {
        t.Errorf("expected 2 HTTP requests (separate cache keys), got %d", callCount)
    }
}

// -------------------------------------------------------
// Quota
// -------------------------------------------------------

func TestQuota_HitsUsageEndpoint(t *testing.T) {
    t.Parallel()
    var cap requestCapture
    srv := capturingServer(t, &cap)
    c := captureClient(t, srv, 0)

    _, err := c.Quota()
    if err != nil {
        t.Fatalf("Quota() error = %v", err)
    }
    if !strings.Contains(cap.req.URL.Path, "/api/usage") {
        t.Errorf("path = %q, expected to contain /api/usage", cap.req.URL.Path)
    }
}

func TestQuota_SendsAPIKeyHeader(t *testing.T) {
    t.Parallel()
    var cap requestCapture
    srv := capturingServer(t, &cap)
    c := captureClient(t, srv, 0)

    _, _ = c.Quota()
    if got := cap.req.Header.Get("X-API-Key"); got != "test-api-key" {
        t.Errorf("X-API-Key = %q, want test-api-key", got)
    }
}

// -------------------------------------------------------
// Construction
// -------------------------------------------------------

func TestNew_RejectsEmptyAPIKey(t *testing.T) {
    t.Parallel()
    _, err := screenshot.New(screenshot.Config{APIKey: ""})
    if err == nil {
        t.Error("expected error for empty API key")
    }
}

func TestNew_DefaultsBaseURL(t *testing.T) {
    t.Parallel()
    c, err := screenshot.New(screenshot.Config{APIKey: "key"})
    if err != nil {
        t.Fatalf("New() error = %v", err)
    }
    _ = c // BaseURL is set; verify indirectly in integration tests
}

Integration Tests: integration_test.go

//go:build integration

package screenshot_test

import (
    "os"
    "strings"
    "testing"

    "github.com/yourorg/screenshot"
)

// TestMain gates the entire integration suite behind SCREENSHOT_API_KEY_LIVE.
func TestMain(m *testing.M) {
    if os.Getenv("SCREENSHOT_API_KEY_LIVE") == "" {
        // Print to stderr so it shows up in verbose mode even when skipped.
        os.Stderr.WriteString("skipping integration tests: SCREENSHOT_API_KEY_LIVE not set\n")
        os.Exit(0)
    }
    os.Exit(m.Run())
}

func liveClient(t *testing.T) *screenshot.Client {
    t.Helper()
    c, err := screenshot.New(screenshot.Config{
        APIKey:  os.Getenv("SCREENSHOT_API_KEY_LIVE"),
        Retries: 2,
    })
    if err != nil {
        t.Fatalf("New() error = %v", err)
    }
    return c
}

func TestLive_CaptureReturnsValidPNG(t *testing.T) {
    c := liveClient(t)

    data, err := c.Capture("https://example.com", screenshot.CaptureOptions{
        Width: 800, Height: 600,
    })
    if err != nil {
        t.Fatalf("Capture() error = %v", err)
    }
    if !strings.HasPrefix(string(data), "\x89PNG\r\n\x1a\n") {
        t.Errorf("response does not start with PNG magic bytes (got %d bytes)", len(data))
    }
    if len(data) < 1000 {
        t.Errorf("PNG too small (%d bytes) — likely an error image", len(data))
    }
}

func TestLive_QuotaReturnsPositiveRemaining(t *testing.T) {
    c := liveClient(t)

    quota, err := c.Quota()
    if err != nil {
        t.Fatalf("Quota() error = %v", err)
    }
    remaining, ok := quota["rate_limit_remaining_today"].(int)
    if !ok {
        t.Fatalf("rate_limit_remaining_today not an int: %T", quota["rate_limit_remaining_today"])
    }
    if remaining < 0 {
        t.Errorf("rate_limit_remaining_today = %d, expected >= 0", remaining)
    }
}

func TestLive_CacheServesSecondCall(t *testing.T) {
    c := liveClient(t)
    url := "https://example.com"
    opts := screenshot.CaptureOptions{Width: 400, Height: 300}

    first, err := c.Capture(url, opts)
    if err != nil {
        t.Fatalf("first Capture() error = %v", err)
    }
    second, err := c.Capture(url, opts)
    if err != nil {
        t.Fatalf("second Capture() error = %v", err)
    }
    if string(first) != string(second) {
        t.Error("expected cache hit to return identical bytes")
    }
}

Running Tests

# Unit tests (fast, no network)
go test ./...

# Verbose with test names
go test -v ./...

# Run a specific test
go test -run TestCapture_URLValidation ./...

# Run table-driven subtests matching a pattern
go test -run "TestCapture_URLValidation/valid" ./...

# Race detector (always recommended)
go test -race ./...

# Integration tests (requires live key)
SCREENSHOT_API_KEY_LIVE=your_key go test -tags integration ./...

# Coverage
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

GitHub Actions CI

name: Tests
on: [push, pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - run: go test -race -count=1 ./...

  integration:
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - run: go test -race -tags integration ./...
        env:
          SCREENSHOT_API_KEY_LIVE: ${{ secrets.SCREENSHOT_API_KEY_LIVE }}

The -count=1 flag disables Go's test result caching, ensuring tests always re-run in CI.

Key Go Testing Patterns

httptest.NewServer — starts a real HTTP server on a random port in the same process. No network stack involved beyond loopback. Deterministic, zero flakiness.

t.Cleanup(srv.Close) — ensures the server shuts down when the test ends, even on failure. No defer juggling across multiple tests.

t.Parallel() — runs subtests concurrently. Table-driven tests with t.Parallel() on each subcase are the fastest way to run a large test suite. Always capture the loop variable (tc := tc) before passing to a parallel subtest.

//go:build integration build tag — cleanly separates integration tests from unit tests. The tag ensures go test ./... never accidentally hits the live API. TestMain adds a second guard for when the tag is applied but the env var is missing.

-count=1 — Go caches successful test results by default. In CI, always use -count=1 to prevent stale cache from masking regressions.

Next Steps