Current section
Files
Jump to
Current section
Files
README.md
# nova_test
Testing library for [Nova](https://github.com/novaframework/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`:
```erlang
{profiles, [
{test, [
{deps, [
{nova_test, "~> 0.3"}
]}
]}
]}.
```
## Integration Tests
Start the application under test and make HTTP requests:
```erlang
-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
```erlang
#{
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`.
### Cookie Management
Carry cookies by hand with `save_cookies/2`:
```erlang
{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:
```erlang
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.
```erlang
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:
```erlang
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:
```erlang
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
```erlang
{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:
```erlang
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
- `new/2` - create a request with method and path
- `with_json/2` - set JSON body and content-type, and `json` decoded from that body (as
`decode_json_body` does, so keys are binaries)
- `with_form/2` - set a form-urlencoded body and `params` (as `read_urlencoded_body` does)
- `with_body/2` - set raw body (not parsed)
- `with_header/3` - add a header
- `with_bindings/2` - set path parameter bindings
- `with_query/2` - set the query string and `parsed_qs` (as `parse_qs => true` does);
pass a list of pairs to repeat a key
- `with_auth_data/2` - set auth context (for security middleware)
- `with_peer/2` - set source IP/port
- `with_multipart/2` - set multipart form-data body
- `with_cookies/2` - set cookie header
## 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:
```erlang
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