Testing

Nova applications can be tested with Erlang's built-in frameworks: EUnit for unit tests and Common Test for integration tests. The nova_test library adds helpers: a request builder for unit testing controllers, an HTTP client for integration tests, and assertion macros.

Adding nova_test

Add nova_test as a test dependency in rebar.config:

{profiles, [
    {test, [
        {deps, [
            {nova_test, "0.1.0"}
        ]}
    ]}
]}.

Database setup for tests

Tests need a running PostgreSQL. Use the same docker-compose.yml from the Database Setup chapter:

docker compose up -d

Your test configuration should point at the test database. You can use the same development database for simplicity, or create a separate one for isolation.

EUnit — Unit testing controllers

Nova controllers are regular Erlang functions that receive a request map and return a tuple. The nova_test_req module builds well-formed request maps so you don't have to construct them by hand.

Create test/blog_posts_controller_tests.erl:

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

show_existing_post_test() ->
    Req = nova_test_req:new(get, "/api/posts/1"),
    Req1 = nova_test_req:with_bindings(#{<<"id">> => <<"1">>}, Req),
    Result = blog_posts_controller:show(Req1),
    ?assertJsonResponse(#{id := 1, title := _}, Result).

show_missing_post_test() ->
    Req = nova_test_req:new(get, "/api/posts/999999"),
    Req1 = nova_test_req:with_bindings(#{<<"id">> => <<"999999">>}, Req),
    Result = blog_posts_controller:show(Req1),
    ?assertStatusResponse(404, Result).

create_post_test() ->
    Req = nova_test_req:new(post, "/api/posts"),
    Req1 = nova_test_req:with_json(#{<<"title">> => <<"Test Post">>,
                                     <<"body">> => <<"Test body">>,
                                     <<"user_id">> => 1}, Req),
    Result = blog_posts_controller:create(Req1),
    ?assertJsonResponse(201, #{id := _}, Result).

create_invalid_post_test() ->
    Req = nova_test_req:new(post, "/api/posts"),
    Req1 = nova_test_req:with_json(#{}, Req),
    Result = blog_posts_controller:create(Req1),
    ?assertStatusResponse(422, Result).

Request builder functions

FunctionPurpose
nova_test_req:new/2Create a request with method and path
nova_test_req:with_bindings/2Set path bindings (e.g. #{<<"id">> => <<"1">>})
nova_test_req:with_json/2Set a JSON body (auto-encodes, sets content-type)
nova_test_req:with_header/3Add a request header
nova_test_req:with_query/2Set query string parameters
nova_test_req:with_body/2Set a raw body
nova_test_req:with_auth_data/2Set auth data (for testing authenticated controllers)
nova_test_req:with_peer/2Set the client peer address

Run EUnit tests:

rebar3 eunit

Testing changesets

Changesets are pure functions — no database needed. Test them directly:

-module(post_changeset_tests).
-include_lib("kura/include/kura.hrl").
-include_lib("eunit/include/eunit.hrl").

valid_changeset_test() ->
    CS = post:changeset(#{}, #{<<"title">> => <<"Good Title">>,
                               <<"body">> => <<"Some content">>}),
    ?assert(CS#kura_changeset.valid).

missing_title_test() ->
    CS = post:changeset(#{}, #{<<"body">> => <<"Some content">>}),
    ?assertNot(CS#kura_changeset.valid),
    ?assertMatch([{title, _} | _], CS#kura_changeset.errors).

title_too_short_test() ->
    CS = post:changeset(#{}, #{<<"title">> => <<"Hi">>,
                               <<"body">> => <<"Content">>}),
    ?assertNot(CS#kura_changeset.valid),
    ?assertMatch([{title, _}], CS#kura_changeset.errors).

invalid_status_test() ->
    CS = post:changeset(#{}, #{<<"title">> => <<"Good Title">>,
                               <<"body">> => <<"Content">>,
                               <<"status">> => <<"invalid">>}),
    ?assertNot(CS#kura_changeset.valid).

valid_email_format_test() ->
    CS = user:changeset(#{}, #{<<"username">> => <<"alice">>,
                               <<"email">> => <<"alice@example.com">>,
                               <<"password_hash">> => <<"hashed">>}),
    ?assert(CS#kura_changeset.valid).

invalid_email_format_test() ->
    CS = user:changeset(#{}, #{<<"username">> => <<"alice">>,
                               <<"email">> => <<"not-an-email">>,
                               <<"password_hash">> => <<"hashed">>}),
    ?assertNot(CS#kura_changeset.valid).

Testing security modules

Test your security functions directly:

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

valid_login_test() ->
    Req = nova_test_req:new(post, "/login"),
    Req1 = nova_test_req:with_json(#{<<"username">> => <<"admin">>,
                                     <<"password">> => <<"password">>}, Req),
    ?assertMatch({true, #{authed := true, username := <<"admin">>}},
                 blog_auth:username_password(Req1)).

invalid_password_test() ->
    Req = nova_test_req:new(post, "/login"),
    Req1 = nova_test_req:with_json(#{<<"username">> => <<"admin">>,
                                     <<"password">> => <<"wrong">>}, Req),
    ?assertEqual(false, blog_auth:username_password(Req1)).

missing_params_test() ->
    Req = nova_test_req:new(post, "/login"),
    ?assertEqual(false, blog_auth:username_password(Req)).

Common Test — Integration testing

Common Test is better for full-stack tests where you need the application running. nova_test provides an HTTP client that handles startup and port discovery.

Create test/blog_api_SUITE.erl:

-module(blog_api_SUITE).
-include_lib("common_test/include/ct.hrl").
-include_lib("nova_test/include/nova_test.hrl").

-export([
         all/0,
         init_per_suite/1,
         end_per_suite/1,
         test_list_posts/1,
         test_create_post/1,
         test_create_invalid_post/1,
         test_get_post/1,
         test_update_post/1,
         test_delete_post/1,
         test_get_post_not_found/1
        ]).

all() ->
    [test_list_posts,
     test_create_post,
     test_create_invalid_post,
     test_get_post,
     test_update_post,
     test_delete_post,
     test_get_post_not_found].

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

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

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

test_create_post(Config) ->
    {ok, Resp} = nova_test:post("/api/posts",
                                #{json => #{<<"title">> => <<"Test Post">>,
                                            <<"body">> => <<"Test body">>,
                                            <<"user_id">> => 1}},
                                Config),
    ?assertStatus(201, Resp),
    ?assertJson(#{<<"title">> := <<"Test Post">>}, Resp).

test_create_invalid_post(Config) ->
    {ok, Resp} = nova_test:post("/api/posts",
                                #{json => #{<<"title">> => <<"Hi">>}},
                                Config),
    ?assertStatus(422, Resp),
    ?assertJson(#{<<"errors">> := _}, Resp).

test_get_post(Config) ->
    %% Create a post first
    {ok, CreateResp} = nova_test:post("/api/posts",
                                      #{json => #{<<"title">> => <<"Get Test">>,
                                                  <<"body">> => <<"Body">>,
                                                  <<"user_id">> => 1}},
                                      Config),
    ?assertStatus(201, CreateResp),
    #{<<"id">> := Id} = nova_test:json(CreateResp),

    %% Fetch it
    {ok, Resp} = nova_test:get("/api/posts/" ++ integer_to_list(Id), Config),
    ?assertStatus(200, Resp),
    ?assertJson(#{<<"title">> := <<"Get Test">>}, Resp).

test_update_post(Config) ->
    %% Create a post first
    {ok, CreateResp} = nova_test:post("/api/posts",
                                      #{json => #{<<"title">> => <<"Before Update">>,
                                                  <<"body">> => <<"Body">>,
                                                  <<"user_id">> => 1}},
                                      Config),
    #{<<"id">> := Id} = nova_test:json(CreateResp),

    %% Update it
    {ok, Resp} = nova_test:put("/api/posts/" ++ integer_to_list(Id),
                                #{json => #{<<"title">> => <<"After Update">>}},
                                Config),
    ?assertStatus(200, Resp),
    ?assertJson(#{<<"title">> := <<"After Update">>}, Resp).

test_delete_post(Config) ->
    %% Create a post first
    {ok, CreateResp} = nova_test:post("/api/posts",
                                      #{json => #{<<"title">> => <<"To Delete">>,
                                                  <<"body">> => <<"Body">>,
                                                  <<"user_id">> => 1}},
                                      Config),
    #{<<"id">> := Id} = nova_test:json(CreateResp),

    %% Delete it
    {ok, Resp} = nova_test:delete("/api/posts/" ++ integer_to_list(Id), Config),
    ?assertStatus(204, Resp).

test_get_post_not_found(Config) ->
    {ok, Resp} = nova_test:get("/api/posts/999999", Config),
    ?assertStatus(404, Resp).

Assertion macros

MacroPurpose
?assertStatus(Code, Resp)Assert the HTTP status code
?assertJson(Pattern, Resp)Pattern-match the decoded JSON body
?assertBody(Expected, Resp)Assert the raw response body
?assertHeader(Name, Expected, Resp)Assert a response header value

Run Common Test suites:

rebar3 ct

Test structure

test/
├── blog_posts_controller_tests.erl       %% EUnit — controller unit tests
├── post_changeset_tests.erl              %% EUnit — changeset validation
├── blog_auth_tests.erl                   %% EUnit — security functions
└── blog_api_SUITE.erl                    %% Common Test — integration tests

Tip

  • Use EUnit for fast unit tests of individual functions and changesets
  • Use Common Test for integration tests that need the full application running
  • Run both with rebar3 do eunit, ct

With testing in place, let's look at how to handle errors gracefully in Error Handling.