Integration Testing

Unit tests verify individual functions. Integration tests verify that the full application works end-to-end — HTTP requests go through routing, plugins, security, controllers, and the database.

Setup

Integration tests use Common Test with nova_test helpers that manage application lifecycle and provide an HTTP client.

Database

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:

%% test sys.config
{blog, [
    {database, <<"blog_test">>}
]}

Writing integration tests

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

Running integration tests

rebar3 ct

Running both

rebar3 do eunit, 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

Next: Testing Real-Time — testing WebSocket handlers and live views.