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=1is 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
- Official SDKs: constructor options, including the base URL used above.
- Error handling and retries: what your code should do with each status.
- Raw HTTP: the exact request and response shapes your stub imitates.
- Verify webhook signatures: signed fixtures for webhook endpoint tests.