NAME
Punk::Test - an in-process test client for Punk applications
SYNOPSIS
use Test::More;
use Punk::Test;
my $t = Punk::Test->new('MyApp');
$t->get_ok('/books')
->status_is(200)
->header_is('Content-Type' => 'application/json')
->json_is('/books/0/title' => 'Neuromancer')
->json_has('/page');
$t->post_ok('/login', form => { user => 'a', pass => 'b' })
->status_is(302)
->header_like(Location => qr{^/});
$t->get_ok('/me')->content_like(qr/hello a/); # the jar kept the session
$t->post_ok('/save', form => { a => 1 }, csrf => 1)
->status_is(200);
$t->sse_ok('/events')
->sse_event_is(0, tick => 'hi')
->sse_json_is(1, '/n' => 2);
$t->websocket_ok('/echo')
->send_ok('hi')
->message_is('echo:hi')
->finish_ok;
done_testing;
DESCRIPTION
One Punk::Test object is one browser against one compiled application: requests run in-process against the same frozen PSGI coderef a server would run, a cookie jar carries the session (and the CSRF mirror cookie) between them, and every assertion returns the object so tests chain. Assertions report through the one Test::Builder singleton, so this works under Test::More and Test2 alike; a failing assertion diags the method, path, status and the head of the body.
The application is compiled once, at new - which means a test is also a boot test.
There are deliberately no HTML-selector assertions: use content_like on the rendered page.
CONSTRUCTOR
new($app, %options)
$app is a class name (to_app is called, after a require if the class is not yet loaded) or a PSGI coderef. to_app compiles once per class, so the frozen coderef is shared: a second client on the same class is a second browser against the same server. Options: timeout (seconds for socket reads, default 5), max_bytes (the most a stream may write without closing before the test dies, default 1MB), csrf_cookie and csrf_header (the mirror cookie read and the header written by csrf => 1, defaults csrf and X-CSRF-Token - match them to a csrf keyword that renames things).
REQUESTS
get_ok / post_ok / put_ok / patch_ok / delete_ok / head_ok / options_ok
$t->get_ok('/path?x=1');
$t->post_ok('/save', form => { a => 1 });
$t->post_ok('/api', json => { a => 1 });
$t->put_ok('/raw', body => $bytes, type => 'application/octet-stream');
One request; the assertion is that the application answered at all (a die fails and diags the error). Options: form (a hashref, url-encoded), json (encoded, application/json), body + type (raw), query (overrides any ?query in the path), headers (a hashref of request headers), csrf (send the jar's CSRF token in the configured header), env (raw PSGI env keys, merged last), name (the test name). A psgi.streaming response is driven to completion and its writes become the body.
login_as($user_or_id)
$t->login_as($user->{id});
$t->get_ok('/account')->status_is(200);
Sign a user straight into the cookie jar: a session cookie minted through the application's own session config (and the auth keyword's session_key), so guarded pages are reachable without driving a login flow first. Takes an id or a user row; needs a client built from a class name. Chainable.
THE RESPONSE
status / body / header($name) / json
The last response's status, body, one header (case-insensitive), and the body decoded as JSON (cached; undef if it does not decode).
cookie($name) / csrf_token / reset_session
One cookie from the jar; the CSRF mirror cookie's value; empty the jar (a fresh browser).
ASSERTIONS
All return $self; all take an optional trailing test name.
status_is($code) / status_isnt($code)
header_is($name, $value) / header_like($name, qr) / header_exists($name)
content_is($body) / content_like(qr) / content_unlike(qr)
json_is($pointer, $want) / json_has($pointer) / json_like($pointer, qr)
The pointer is RFC 6901: '' is the whole document, '/a/0/b' walks in. json_is deep-compares references through canonical JSON.
SERVER-SENT EVENTS
sse_ok($path, %options)
Drive an sse route through the psgi.streaming transport to completion and parse the stream. The assertion is a 200 text/event-stream. The handler must close deterministically: a stream that writes more than max_bytes without closing dies the test rather than hanging it.
sse_event_is($i, $event, $data) / sse_data_is($i, $data) / sse_json_is($i, $pointer, $want)
Assert on the i-th dispatched event: its event: name and data, its data alone, or its data decoded as JSON at a pointer.
sse_events / sse_comments
The parsed events (hashrefs of event, data, id, retry) and comment lines, for anything the assertions above do not cover.
WEBSOCKETS
websocket_ok($path, %options)
Open a websocket and assert the 101 - the Sec-WebSocket-Accept is verified against this module's own independent digest. Options: protocol (offer a subprotocol), headers, live (skip straight to the live transport), name.
Two transports, chosen automatically. A blocking => 1 route runs in-process: the application runs in a fork with one end of a socketpair as psgix.io, so the conversation is fully interactive and no server is needed. Any other route needs the live transport - a real Hyperman worker forked once per client on first use - because only Hyperman can detach the socket. Guard live-transport tests with "ws_live_available", the way the dist's own suite does.
send_ok($payload, %options)
Send one frame - masked, as a client must. binary => 1 sends opcode 2; opcode overrides outright.
message_is($want) / message_like(qr)
The next data message from the server (fragments reassembled, pongs skipped) compared to a string or a pattern.
finish_ok
Send a clean close (1000) and assert the server echoes it; then tear the connection down.
ws_live_available
plan skip_all => 'Hyperman required'
unless Punk::Test::ws_live_available;
Whether the live transport can run here at all: Hyperman 0.11+ with the detach ABI. A function, not a method.
SEE ALSO
AUTHOR
LNATION <email@lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION <email@lnation.org>.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)