How to Test Screenshot API Integrations with Go
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
- Review the Go integration guide for the full client implementation
- Check your API quota at hermesforge.dev/api/usage
- For other languages: pytest guide | Jest guide | RSpec guide | PHPUnit guide