nova_test

Testing library for Nova framework applications.

Provides an HTTP client for integration tests, a WebSocket client, a mock request builder for unit tests, cookie sessions with CSRF helpers, multipart uploads, and request/response logging.

Installation

Add nova_test to your test dependencies in rebar.config:

{profiles, [
    {test, [
        {deps, [
            {nova_test, "~> 0.3"}
        ]}
    ]}
]}.

Integration Tests

Start the application under test and make HTTP requests:

-include_lib("nova_test/include/nova_test.hrl").

init_per_suite(Config) ->
    nova_test:start(my_app, Config).

end_per_suite(Config) ->
    nova_test:stop(Config).

get_users_test(Config) ->
    {ok, Resp} = nova_test:get("/api/users", Config),
    ?assertStatus(200, Resp),
    ?assertJson(#{<<"users">> := _}, Resp).

create_user_test(Config) ->
    {ok, Resp} = nova_test:post("/api/users", #{json => #{<<"name">> => <<"alice">>}}, Config),
    ?assertStatus(201, Resp).

Paths must start with /. Any other path raises {nova_test_invalid_path, #{path := Path, reason := Reason}}, because a path such as "@other-host/x" would send the request, with its cookies and headers, to another host. nova_test_ws:connect/2,3 checks its path the same way.

nova_test:start/1,2 starts the application and its dependencies and reads the port the HTTP listener actually bound from Ranch, so #{port => 0} in cowboy_configuration works. If the listener is not running (Nova only logs "Cowboy could not start" when the port is taken), start stops what it started and raises {nova_test_listener_not_running, #{app := App, reason := Reason, configured_port := Port}} instead of letting the tests talk to whatever owns the port. An application that was already running before the call is left running.

When the application has several listeners, start uses the one bound to the port cowboy_configuration asks for (ssl_port with use_ssl => true, otherwise port).

Only plain HTTP listeners are supported. With use_ssl => true in cowboy_configuration, start stops what it started and raises {nova_test_tls_not_supported, #{app := App, port := Port}}; test against a plain HTTP listener (for example a test profile without use_ssl).

Call nova_test:stop/1 last: it stops exactly the applications start started, in reverse start order (the application, then nova, cowboy, ranch, inets and any other dependency that call started), so the next suite gets a fresh listener. Applications that were already running before start are left running. When two start calls share dependencies, the first call started them, so stop the second one first.

Request Options

#{
    headers => [{<<"authorization">>, <<"Bearer token">>}],
    json => #{<<"key">> => <<"value">>},
    form => [{<<"email">>, <<"alice@example.com">>}],   %% application/x-www-form-urlencoded
    body => <<"raw bytes">>,
    content_type => "text/plain",
    multipart => [
        {field, <<"name">>, <<"alice">>},
        {file, <<"avatar">>, <<"photo.png">>, <<"image/png">>, FileData}
    ]
}

JSON

json request bodies, nova_test:json/1, nova_test_req:with_json/2 and the WebSocket JSON helpers go through nova_json when the Nova in use has it. Otherwise they read json_lib in the same order Nova does: the bootstrap application's environment, then nova's. When neither sets it, for example in EUnit without a running app, they use thoas, the same default as Nova. Set json_lib (for example to OTP's json) to use another library in both. Decode failures raise {json_decode_failed, Reason}. A json_lib module may return {ok, Term} or the term itself from decode/1.

Carry cookies by hand with save_cookies/2:

{ok, Resp} = nova_test:post("/login", #{json => Credentials}, Config),
Config2 = nova_test:save_cookies(Resp, Config),
%% Requests made with Config2 send the saved cookies
{ok, Resp2} = nova_test:get("/dashboard", Config2),
?assertStatus(200, Resp2).

Cookies the response expires (Max-Age=0, or an Expires date in the past) are removed.

Sessions and CSRF

nova_test:session/1 attaches a cookie jar to the Config. Every request made with it, including nova_test_ws:connect/2,3, sends the jar's cookies that match the request path and stores the cookies the response sets, like a browser. Nova sessions and nova_csrf_plugin form flows then work without calling save_cookies/2. The jar keys cookies on name and Path (RFC 6265 path matching and default path); Domain, Secure and HttpOnly are ignored, since every request goes to the application under test. nova_test:cookie_header/2 returns the cookie header for a path, for use with another client. A cookie header you pass in headers is merged with the jar's into one header, your pairs first.

To assert cookie attributes, read them from the response with nova_test:set_cookies/1. It returns every Set-Cookie header (nova_test:header/2 returns only the first) as #{name, value, attributes}, with lowercased attribute names and <<>> for flags:

session_cookie_flags_test(Config) ->
    Session = nova_test:session(Config),
    {ok, Resp} = nova_test:get("/login", Session),
    [#{attributes := Attrs}] =
        [C || #{name := <<"session_id">>} = C <- nova_test:set_cookies(Resp)],
    ?assert(lists:keymember(<<"httponly">>, 1, Attrs)),
    ?assert(lists:keymember(<<"secure">>, 1, Attrs)),
    ?assertEqual({<<"samesite">>, <<"Lax">>}, lists:keyfind(<<"samesite">>, 1, Attrs)).

nova_test:csrf_token/1 follows nova_csrf_plugin's conventions: it reads the value of the page's <input name="_csrf_token">, and otherwise the x-csrf-token response header. The plugin only reads that header from requests and never sets it on responses, so the fallback finds a token only when your application sets the header itself. If you changed the plugin's field_name or header_name, pass them: nova_test:csrf_token(#{field => <<"csrf">>, header => "x-xsrf-token"}, Resp).

csrf_token returns undefined when the page has no token. A form value of undefined raises {nova_test_undefined_form_value, #{field := Name, reason := Reason}} instead of posting the string "undefined", so a missing token fails the test where it happens.

log_in_test(Config) ->
    Session = nova_test:session(Config),
    {ok, Form} = nova_test:get("/login", Session),
    {ok, Resp} = nova_test:post("/login", #{form => [
        {<<"_csrf_token">>, nova_test:csrf_token(Form)},
        {<<"email">>, <<"alice@example.com">>},
        {<<"password">>, <<"secret">>}
    ]}, Session),
    ?assertStatus(302, Resp),
    {ok, Page} = nova_test:get("/dashboard", Session),
    ?assertStatus(200, Page).

For the negative case, post from a session that holds a token. nova_csrf_plugin also answers 403 when there is no session at all, so a 403 from a fresh client proves nothing about the token check:

csrf_rejected_test(Config) ->
    Session = nova_test:session(Config),
    {ok, Form} = nova_test:get("/login", Session),
    ?assert(is_binary(nova_test:csrf_token(Form))),
    {ok, Missing} = nova_test:post("/login", #{form => [{<<"email">>, <<"a@b">>}]}, Session),
    ?assertStatus(403, Missing),
    {ok, Forged} = nova_test:post("/login", #{form => [
        {<<"_csrf_token">>, <<"forged">>},
        {<<"email">>, <<"a@b">>}
    ]}, Session),
    ?assertStatus(403, Forged).

Call session/1 once per simulated client and use each jar from one process at a time. The jar is an ETS table owned by the calling process: create it in the test case or init_per_testcase/2, not in init_per_suite/1 or init_per_group/2, whose processes exit before the test cases run.

Debugging

Enable request/response logging to see what's happening in CT logs:

Config2 = nova_test:enable_logging(Config),
{ok, _} = nova_test:get("/api/users", Config2).
%% Prints method, path, headers, body, status to ct:pal

The values of the authorization, cookie, set-cookie and x-csrf-token headers and of the password, password_confirmation and _csrf_token form fields are logged as [redacted]. JSON and raw bodies are logged as sent. The list is fixed: a custom CSRF field or header passed to csrf_token/2 is not redacted.

WebSocket Tests

{ok, Conn} = nova_test_ws:connect("/ws", Config),
ok = nova_test_ws:send_json(#{<<"action">> => <<"ping">>}, Conn),
?assertWsRecvJson(#{<<"action">> := <<"pong">>}, Conn),
ok = nova_test_ws:close(Conn).

The upgrade request carries the cookies the Config holds for that path, so a WebSocket behind a session login works with Session = nova_test:session(Config).

Unit Tests (Mock Requests)

Build mock Cowboy requests to test controllers directly without HTTP. The builders fill the keys Nova's nova_request_plugin would, so controllers see the same request map:

Req = nova_test_req:new(post, "/api/users"),
Req1 = nova_test_req:with_json(#{<<"name">> => <<"alice">>}, Req),
Req2 = nova_test_req:with_auth_data(#{role => admin}, Req1),
Result = my_controller:create(Req2),
?assertJsonResponse(201, #{<<"id">> := _}, Result).

Available Builders

Assertion Macros

Include -include_lib("nova_test/include/nova_test.hrl"). for all macros.

Response Assertions (Integration Tests)

Macro Description
?assertStatus(Code, Resp) Assert HTTP status code
?assertJson(Pattern, Resp) Pattern match on decoded JSON body
?assertBody(Expected, Resp) Assert exact body match
?assertHeader(Name, Value, Resp) Assert response header value
?assertCookie(Name, Value, Config) Assert cookie value in Config

WebSocket Assertions

Macro Description
?assertWsRecv(Pattern, Conn) Receive and pattern match text frame
?assertWsRecvJson(Pattern, Conn) Receive and pattern match JSON frame

Controller Return Assertions (Unit Tests)

Macro Description
?assertJsonResponse(Pattern, Result) Match {json, Body}
?assertJsonResponse(Status, Pattern, Result) Match {json, Status, _, Body}
?assertStatusResponse(Code, Result) Match {status, Code}
?assertRedirect(Target, Result) Match {redirect, Target}
?assertOkResponse(Pattern, Result) Match {ok, Variables}

Test Generators

Every macro has a ?_assert... variant that returns an EUnit test object instead of running the assertion, for use in _test_() generators:

user_test_() ->
    {setup, fun start/0, fun stop/1, fun(Config) ->
        {ok, Resp} = nova_test:get("/api/users/1", Config),
        [?_assertStatus(200, Resp), ?_assertJson(#{<<"id">> := 1}, Resp)]
    end}.

License

MIT