Test code that calls the screenshot API

Your test suite should not take real screenshots. Real calls use credits, take seconds, depend on the network and on a third-party page, and fail for reasons that have nothing to do with your code. This guide, part of the guides hub, shows how to replace RenderScreenshot in tests with each official SDK.

Every listing except Java was run on 2026-10-15 and passed: Node 22.18 with node:test and renderscreenshot 1.1.0; Python 3.14 with pytest 9.1, respx 0.23 and the Python SDK 1.0.0; Ruby 4.0 with Minitest 6.0, WebMock 3.34 and the renderscreenshot gem 1.0.0; PHP 8.5 with PHPUnit 13.4; Go 1.27 with rs-go 1.1.0. The Java listing was not run.

Two kinds of test

Unit test Contract test
Replaces The SDK client object The HTTP server
Checks Your logic: what you do with bytes and errors The exact request on the wire: path, auth header, JSON body
Speed Microseconds Milliseconds, local only
Catches Bugs in your code Wrong option names, wrong nesting, SDK upgrades that change the request
Tools mock.fn, create_autospec, a fake object, createMock, an interface, Mockito A local server, respx, WebMock, httptest

Write many unit tests and a few contract tests. Add live calls only as opt-in smoke tests (see the end of this page).

The contract test is the one people skip, and it is the one that catches silent problems. The POST endpoint reads options only from their documented groups, such as output.format or browser.dark_mode. A misplaced field is ignored rather than rejected, so the capture still succeeds with the wrong settings. An assertion on the JSON body shows you exactly what your SDK version sends.

The code under test

Every example tests the same small function. It asks for an og_card image and turns a rate limit into your own RetryLater error, so a job runner can reschedule:

socialCard(client, pageUrl):
    return client.take(url = pageUrl, preset = "og_card")
    on an error with HTTP status 429: raise RetryLater

Pass the client in as an argument (or a constructor parameter). That one decision is what makes every test below possible without patching globals.

Assert on the HTTP status of the error, not on the code string. The status is the same across every SDK and every API version.

Node.js

The SDK uses the global fetch. For contract tests, point the client at a local server with the documented baseUrl option.

// cards.mjs - the code under test
import { TakeOptions, RenderScreenshotError } from 'renderscreenshot';

export class RetryLater extends Error {}

export async function socialCard(client, pageUrl) {
  try {
    return await client.take(TakeOptions.url(pageUrl).preset('og_card'));
  } catch (err) {
    if (err instanceof RenderScreenshotError && err.httpStatus === 429) {
      throw new RetryLater(`retry in ${err.retryAfter ?? 60}s`);
    }
    throw err;
  }
}
// cards.unit.test.mjs - node --test (Node 20+)
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
import { RenderScreenshotError } from 'renderscreenshot';
import { socialCard, RetryLater } from './cards.mjs';

test('returns the image bytes', async () => {
  const client = { take: mock.fn(async () => Buffer.from('fake-png')) };

  const bytes = await socialCard(client, 'https://example.com/post/1');

  assert.equal(bytes.toString(), 'fake-png');
  assert.equal(client.take.mock.callCount(), 1);
});

test('turns a 429 into RetryLater', async () => {
  const client = {
    take: mock.fn(async () => {
      throw new RenderScreenshotError(429, 'rate_limited', 'Rate limit exceeded', 30);
    }),
  };

  await assert.rejects(socialCard(client, 'https://example.com'), RetryLater);
});
// cards.contract.test.mjs - the real SDK against a local fake API
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { Client } from 'renderscreenshot';
import { socialCard, RetryLater } from './cards.mjs';

const PNG = Buffer.from('89504e470d0a1a0a', 'hex'); // PNG signature is enough for a fixture
let server;
let requests = [];
let nextResponse;

before(async () => {
  server = createServer((req, res) => {
    let body = '';
    req.on('data', (c) => (body += c));
    req.on('end', () => {
      requests.push({ method: req.method, url: req.url, auth: req.headers.authorization, body: JSON.parse(body || '{}') });
      nextResponse(res);
    });
  });
  await new Promise((r) => server.listen(0, '127.0.0.1', r));
});
after(() => server.close());

const client = () =>
  new Client('rs_live_test', { baseUrl: `http://127.0.0.1:${server.address().port}` });

test('sends the documented request and returns the bytes', async () => {
  requests = [];
  nextResponse = (res) => res.writeHead(200, { 'Content-Type': 'image/png' }).end(PNG);

  const bytes = await socialCard(client(), 'https://example.com/post/1');

  assert.deepEqual(bytes, PNG);
  assert.deepEqual(requests[0], {
    method: 'POST',
    url: '/v1/screenshot',
    auth: 'Bearer rs_live_test',
    body: { url: 'https://example.com/post/1', preset: 'og_card' },
  });
});

test('maps a real 429 response to RetryLater', async () => {
  nextResponse = (res) =>
    res
      .writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '30' })
      .end(JSON.stringify({ error: { type: 'rate_limit_error', code: 'too_many_requests', message: 'Rate limit exceeded', retryable: true } }));

  await assert.rejects(socialCard(client(), 'https://example.com'), RetryLater);
});

The same tests work in Vitest or Jest: replace mock.fn with vi.fn or jest.fn. If you prefer request interception to a real local server, MSW intercepts fetch and works the same way.

Python

The SDK sends requests with httpx, so respx can intercept them without a server. create_autospec builds a mock that fails if you call a method the real Client does not have.

# cards.py - the code under test
from renderscreenshot import Client, RenderScreenshotError, TakeOptions


class RetryLater(Exception):
    pass


def social_card(client: Client, page_url: str) -> bytes:
    try:
        return client.take(TakeOptions.url(page_url).preset("og_card"))
    except RenderScreenshotError as e:
        if e.http_status == 429:
            raise RetryLater(f"retry in {e.retry_after or 60}s") from e
        raise
# test_cards.py - pytest 8+, respx 0.22+
import json
from unittest.mock import create_autospec

import httpx
import pytest
import respx
from renderscreenshot import Client, RenderScreenshotError

from cards import RetryLater, social_card

PNG = bytes.fromhex("89504e470d0a1a0a")


# Unit tests: no HTTP at all. create_autospec fails if you call a method the SDK does not have.
def test_returns_image_bytes():
    client = create_autospec(Client, instance=True)
    client.take.return_value = PNG

    assert social_card(client, "https://example.com/post/1") == PNG
    client.take.assert_called_once()


def test_429_becomes_retry_later():
    client = create_autospec(Client, instance=True)
    client.take.side_effect = RenderScreenshotError(429, "rate_limited", "Rate limit exceeded", 30)

    with pytest.raises(RetryLater):
        social_card(client, "https://example.com")


# Contract tests: the real SDK, with httpx traffic intercepted by respx.
@respx.mock
def test_sends_documented_request():
    route = respx.post("https://api.renderscreenshot.com/v1/screenshot").mock(
        return_value=httpx.Response(200, content=PNG, headers={"Content-Type": "image/png"})
    )

    assert social_card(Client("rs_live_test"), "https://example.com/post/1") == PNG

    sent = route.calls.last.request
    assert sent.headers["Authorization"] == "Bearer rs_live_test"
    assert json.loads(sent.content) == {"url": "https://example.com/post/1", "preset": "og_card"}


@respx.mock
def test_real_429_body_becomes_retry_later():
    respx.post("https://api.renderscreenshot.com/v1/screenshot").mock(
        return_value=httpx.Response(
            429,
            headers={"Retry-After": "30"},
            json={"error": {"type": "rate_limit_error", "code": "too_many_requests",
                            "message": "Rate limit exceeded", "retryable": True}},
        )
    )

    with pytest.raises(RetryLater):
        social_card(Client("rs_live_test"), "https://example.com")

@respx.mock also blocks any request you did not stub, so a forgotten real call fails the test instead of using a credit.

Ruby

The gem sends requests with Faraday on Net::HTTP, which WebMock intercepts. WebMock also blocks all other outgoing HTTP by default. A plain fake object is enough for unit tests and does not depend on the version of a mocking library.

# cards.rb - the code under test
require 'renderscreenshot'

class RetryLater < StandardError; end

def social_card(client, page_url)
  client.take(RenderScreenshot::TakeOptions.url(page_url).preset('og_card'))
rescue RenderScreenshot::RateLimitError => e
  raise RetryLater, "retry in #{e.retry_after || 60}s"
end
# cards_test.rb - Minitest 5 or 6, WebMock 3
require 'minitest/autorun'
require 'webmock/minitest'
require 'json'
require_relative 'cards'

class SocialCardTest < Minitest::Test
  ENDPOINT = 'https://api.renderscreenshot.com/v1/screenshot'
  PNG = ['89504e470d0a1a0a'].pack('H*')

  # Unit test: a stand-in object that answers #take, no HTTP at all
  FakeClient = Struct.new(:calls) do
    def take(options)
      calls << options
      PNG
    end
  end

  def test_returns_image_bytes
    client = FakeClient.new([])

    assert_equal PNG, social_card(client, 'https://example.com/post/1')
    assert_equal 1, client.calls.size
  end

  # Contract tests: the real SDK, with HTTP intercepted by WebMock
  def test_sends_documented_request
    stub_request(:post, ENDPOINT)
      .with(headers: { 'Authorization' => 'Bearer rs_live_test' },
            body: { url: 'https://example.com/post/1', preset: 'og_card' })
      .to_return(status: 200, body: PNG, headers: { 'Content-Type' => 'image/png' })

    assert_equal PNG, social_card(RenderScreenshot.client('rs_live_test'), 'https://example.com/post/1')
  end

  def test_real_429_becomes_retry_later
    stub_request(:post, ENDPOINT).to_return(
      status: 429,
      headers: { 'Content-Type' => 'application/json', 'Retry-After' => '30' },
      body: { error: { type: 'rate_limit_error', code: 'too_many_requests',
                       message: 'Rate limit exceeded', retryable: true } }.to_json
    )

    assert_raises(RetryLater) { social_card(RenderScreenshot.client('rs_live_test'), 'https://example.com') }
  end
end

With RSpec, use instance_double(RenderScreenshot::Client, take: png) for unit tests and the same stub_request calls for contract tests.

PHP

RenderScreenshot\Client is not a final class, so PHPUnit can mock it directly.

<?php
// src/Cards.php - the code under test
namespace App;

use RenderScreenshot\Client;
use RenderScreenshot\RenderScreenshotException;
use RenderScreenshot\TakeOptions;

final class Cards
{
    public function __construct(private Client $client) {}

    public function socialCard(string $pageUrl): string
    {
        try {
            return $this->client->take(TakeOptions::url($pageUrl)->preset('og_card'));
        } catch (RenderScreenshotException $e) {
            if ($e->httpStatus === 429) {
                throw new RetryLater('retry in ' . ($e->retryAfter ?? 60) . 's', 0, $e);
            }
            throw $e;
        }
    }
}
<?php
// tests/CardsTest.php - PHPUnit 10 or later
use App\Cards;
use App\RetryLater; // a plain RuntimeException subclass in src/RetryLater.php
use PHPUnit\Framework\TestCase;
use RenderScreenshot\Client;
use RenderScreenshot\RenderScreenshotException;
use RenderScreenshot\TakeOptions;

final class CardsTest extends TestCase
{
    public function testReturnsImageBytes(): void
    {
        $client = $this->createMock(Client::class);
        $client->expects($this->once())
            ->method('take')
            ->with($this->isInstanceOf(TakeOptions::class))
            ->willReturn("\x89PNG\r\n\x1a\n");

        $this->assertSame("\x89PNG\r\n\x1a\n", (new Cards($client))->socialCard('https://example.com/post/1'));
    }

    public function testRateLimitBecomesRetryLater(): void
    {
        $client = $this->createStub(Client::class);
        $client->method('take')
            ->willThrowException(new RenderScreenshotException(429, 'rate_limited', 'Rate limit exceeded', 30));

        $this->expectException(RetryLater::class);
        (new Cards($client))->socialCard('https://example.com');
    }
}

For a wire-level test in PHP, run a stub server (WireMock, or a small route in your own app) and pass its address as the second constructor argument, $baseUrl.

Go

The SDK does not take an http.Client, so define a one-method interface in your package and let *rs.Client satisfy it. For contract tests, point the real client at httptest.Server with rs.WithBaseURL.

// cards.go - the code under test
package cards

import (
    "context"
    "errors"
    "fmt"

    rs "github.com/Render-Screenshot/rs-go"
)

// Screenshotter is the one method this package needs. *rs.Client satisfies it.
type Screenshotter interface {
    Take(ctx context.Context, opts *rs.TakeOptions) ([]byte, error)
}

var ErrRetryLater = errors.New("retry later")

func SocialCard(ctx context.Context, c Screenshotter, pageURL string) ([]byte, error) {
    img, err := c.Take(ctx, rs.URL(pageURL).Preset("og_card"))
    if rs.IsRateLimited(err) {
        return nil, fmt.Errorf("%w: %v", ErrRetryLater, err)
    }
    return img, err
}
// cards_test.go - go test
package cards

import (
    "bytes"
    "context"
    "encoding/json"
    "errors"
    "io"
    "net/http"
    "net/http/httptest"
    "testing"

    rs "github.com/Render-Screenshot/rs-go"
)

var png = []byte("\x89PNG\r\n\x1a\n")

// Unit test: a fake that satisfies the interface, no HTTP.
type fakeClient struct{ calls int }

func (f *fakeClient) Take(ctx context.Context, opts *rs.TakeOptions) ([]byte, error) {
    f.calls++
    return png, nil
}

func TestSocialCardReturnsBytes(t *testing.T) {
    f := &fakeClient{}
    img, err := SocialCard(context.Background(), f, "https://example.com/post/1")
    if err != nil || !bytes.Equal(img, png) || f.calls != 1 {
        t.Fatalf("got %q, %v, %d calls", img, err, f.calls)
    }
}

// Contract tests: the real SDK pointed at httptest.Server with WithBaseURL.
func TestSocialCardSendsDocumentedRequest(t *testing.T) {
    var gotPath, gotAuth string
    var gotBody map[string]any
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        gotPath, gotAuth = r.Method+" "+r.URL.Path, r.Header.Get("Authorization")
        raw, _ := io.ReadAll(r.Body)
        json.Unmarshal(raw, &gotBody)
        w.Header().Set("Content-Type", "image/png")
        w.Write(png)
    }))
    defer srv.Close()

    client, err := rs.New("rs_live_test", rs.WithBaseURL(srv.URL))
    if err != nil {
        t.Fatal(err)
    }
    img, err := SocialCard(context.Background(), client, "https://example.com/post/1")
    if err != nil || !bytes.Equal(img, png) {
        t.Fatalf("got %q, %v", img, err)
    }
    if gotPath != "POST /v1/screenshot" || gotAuth != "Bearer rs_live_test" {
        t.Fatalf("request was %s with %q", gotPath, gotAuth)
    }
    if gotBody["url"] != "https://example.com/post/1" || gotBody["preset"] != "og_card" {
        t.Fatalf("body was %v", gotBody)
    }
}

func TestSocialCardMaps429(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.Header().Set("Retry-After", "30")
        w.WriteHeader(http.StatusTooManyRequests)
        w.Write([]byte(`{"error":{"type":"rate_limit_error","code":"too_many_requests","message":"Rate limit exceeded","retryable":true}}`))
    }))
    defer srv.Close()

    client, _ := rs.New("rs_live_test", rs.WithBaseURL(srv.URL))
    _, err := SocialCard(context.Background(), client, "https://example.com")
    if !errors.Is(err, ErrRetryLater) {
        t.Fatalf("want ErrRetryLater, got %v", err)
    }
}

Java

com.renderscreenshot.sdk.Client is not final, so Mockito can mock it. For contract tests, OkHttp's MockWebServer and the Client(apiKey, baseUrl, timeout) constructor follow the same pattern as the Go example.

// CardsTest.java - JUnit 5, Mockito 5 (not run for this guide)
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

import com.renderscreenshot.sdk.Client;
import com.renderscreenshot.sdk.RenderScreenshotException;
import com.renderscreenshot.sdk.TakeOptions;
import org.junit.jupiter.api.Test;

class CardsTest {
    static final byte[] PNG = {(byte) 0x89, 'P', 'N', 'G'};

    @Test
    void returnsImageBytes() {
        Client client = mock(Client.class);
        when(client.take(any(TakeOptions.class))).thenReturn(PNG);

        assertArrayEquals(PNG, new Cards(client).socialCard("https://example.com/post/1"));
        verify(client, times(1)).take(any(TakeOptions.class));
    }

    @Test
    void rateLimitBecomesRetryLater() {
        Client client = mock(Client.class);
        when(client.take(any(TakeOptions.class)))
            .thenThrow(new RenderScreenshotException("Rate limit exceeded", 429, "rate_limited", true, 30));

        assertThrows(Cards.RetryLater.class, () -> new Cards(client).socialCard("https://example.com"));
    }
}

Cards here is the same function as above: it calls client.take(TakeOptions.url(pageUrl).preset("og_card")) and catches RenderScreenshotException with getHttpStatus() == 429.

Fixtures worth keeping

Keep these responses as fixtures and replay them from your stub. Their shapes come from the error codes reference and the raw HTTP guide.

Scenario Status Headers Body
Success 200 Content-Type: image/png, X-Cache-URL A few PNG bytes
Success, JSON mode 200 Content-Type: application/json {"image": {"url": ..., "width": 1200, "height": 630}}
Bad input 400 {"error": {"type": "validation_error", "code": "invalid_url", ...}}
Wrong key 401 {"error": {"type": "authentication_error", "code": "invalid_api_key", ...}}
No credits left 403 {"error": {"type": "authorization_error", "code": "insufficient_credits", ...}}
Page failed to render 422 {"error": {"type": "target_error", ...}}
Rate limited 429 Retry-After: 30 {"error": {"type": "rate_limit_error", "code": "too_many_requests", ...}}

Two fixtures are often missing: a 403 for an account with no credits left, which must not be retried, and a 422 that succeeds on the second try, which must be retried.

Live smoke tests

Keep one or two tests that call the real API, and run them only when you ask for them:

  • Skip them unless an environment variable such as RS_LIVE_TESTS=1 is set.
  • Use a dedicated API key so the usage is easy to spot in the dashboard.
  • Capture a page you control, or https://example.com. A third-party page can change and fail your build.
  • Assert on the shape of the result (non-empty bytes, Content-Type, width and height from JSON mode), not on pixels.

Each successful live render uses a credit, so run them on a schedule or before a release, not on every commit.

Troubleshooting

Symptom Cause Fix
Tests use credits A test reached the real API Block real HTTP (respx, WebMock), or inject a fake client
Mock passes, production fails The mock accepts a method or option the SDK does not have Use autospec or interface fakes; add one contract test
An option works in your mock but not in production The real request body has a different shape Assert the JSON body in a contract test
Error test fails to match Test checks the code string Check the HTTP status (httpStatus, http_status, getHttpStatus())
Contract test hangs The client waits for its full timeout Set a short timeout in tests, and always answer from the stub
Flaky live test Third-party page changed or was slow Capture a page you control; keep live tests opt-in

Related in this topic

Was this page helpful?