Now that we have a running web server, an important topic to cover is testing. We want to start writing tests as soon as possible, since bugs are much cheaper to fix early in the development process.
We can write tests against our API using hspec-wai. Using the library we bypass the network and HTTP layers and match requests directly against out handlers. This simplifies and speeds up the tests (we don't want to test the web server or our OS' networks stack)
Let's a test for our API.
First we need to import the relevant modules:
import Data.Data (Proxy(..))
import Test.Hspec
import Test.Hspec.Wai as Wai
import Test.Hspec.Wai.JSON (json)
import NejlaCommon.Test as NC
import RunMain ( AppState(..) , AppConfig(..)
, migrate, serveApp
)
First we need to create a configuration that our web server should run it:
testAppState =
AppState
{ appStateConfig =
AppConfig
{ appConfigEmail = "test@example.com"
}
}Next we want to write a simple test case; we create a user and retrieve it,
hoping that we get the same data back. Using
hspec syntax we describe the
thing under test (in this case, the /users resource). Then we use it to
start a test case. it takes a textual description of what we are testing (it
should read like a sentence) and a test script.
specs :: DBApiSpec ()
specs = do
describe "/users" $ do
it "accepts a POST request" $ do
postJ "/users"
[json|{ "name": "Robert"
, "points": 33
, "group": "development"
}
|] `shouldRespondWith` 201
Some things of note:
- postJ (short for "post json" creates a POST request to a resource with the "Content-Type" header set to "application/json"
- The
[json| ... |]construction is a quasi quoter, allowing us to write literal json that is converted to Haskell code during compilation. - Don't muss the
shouldRespondWith, which checks that the call reponds with the expected error code.
finally we need to write a main function that runs these tests:
main :: IO ()
main = do
connectInfo <- dbTestConnectInfo
specApi connectInfo migrate mkApi specs
where
mkApi pool run =
liftIO $ run () (serveApp pool testAppState)- dbTestConnectInfo reads database connection information from environment variables, defaulting to the PostgreSQL defaults when the variables are unset
- specApi connects to the database, clears it out and runs the migration (the one we have imported from our library) and cleans the database between every test (by running "DELETE FROM ..." on every table.
- NB this is obviously destructive. Never run the test suite with a database that contains data you want to keep.
- mkApi allows us to run additional setup before and after every test. It get's the (already migrated and cleaned) database pool and the actual test which we have to call with additional state. Say we needed to create a temporary directory for every test case (and clean it up after the test is done, even when an exception occurs), then we could write:
mkApi pool run = liftIO $
withSystemTemporaryDirectory "my-temp-dir" $ \path ->
run path (ServeApp pool testAppState)Our tests will need access to our infrastructure - at least a database. Since we are already using Docker to coordinate the various parts of our system it stands to reason that we should rely on it for running the test suite too.
Firstly, we need to define a Dockerfile to run the tests in. Docker has multi-stage builds which allow us to re-use the Dockerfile we use for our production build, which not only saves us effort but also makes it easier to ensure that the testing and production environments stay in sync. All we need to do to enable multi-stage builds is to add stage annotations to our existing Dockerfile like this:
FROM ubuntu:eoan AS baseimage
LABEL project=nejlacommon-tutorial
RUN export DEBIAN_FRONTEND=noninteractive && \
apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
libicu-dev \
libpq-dev \
locales \
netbase \
&& \
rm -rf /var/lib/apt/lists/* && \
localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8
ENV LANG=en_US.UTF-8
FROM baseimage
LABEL project=nejlacommon-tutorial
ADD ./dist/* /app/
ENTRYPOINT ["/app/nejlacommon-tutorial"]note the FROM «baseimage» as «stage» line at the top, it declares that we are
starting a new stage. We also added a new line FROM «stage» in the middle,
indicating that everything that comes after builds on top of the earlier stage.
Now we can create the baseimage via docker build . --target baseimage. Instead of doing that by hand, though, we will write a docker-compose file, docker-compose.testing.yaml :
version: '3.4'
services:
testing-database:
image: postgres:12
environment:
POSTGRES_HOST_AUTH_METHOD: trust
networks:
test:
command: -c "fsync=off" -c "synchronous_commit=off" -c "full_page_writes=off"
test:
build:
context: .
target: baseimage
environment:
DB_HOST: testing-database
volumes:
- ./dist/tests:/tests:ro
networks:
test:
links:
- testing-database
command: sh -ec 'for t in /tests/*; do $$t; done'
networks:
test:Let's go over this file step by step.
- We need a compose-file version of at least 3.4 for Dockerfile stages to work.
- Next we declare the database that we want to use for testing, similarly to the one we declared for production. We pass it some additional parameters that turn off data integrity for a bit of improved performance (since we don't really care about data loss in the testing database in case of a crash)
- Next we declare the test service.
- We want to use the baseimage as declared in the Dockerfile, docker-compose will automatically build it if necessary.
- We tell our test suite how to reach the database by setting the DB_HOST environment varliable. The test-database host will be reachable within the docker container because it's in the same network named "test"
- We volume in the built test executable (read only). We could bake it into an image, but that's unnecessary since we only want to run it once locally and not distribute it
- As mentioned earlier, we want the test container to be in the same network as the database so it can reach it.
- Declaring a link ensure that
docker-compose runwill start the database service for us when we run thetestservice as a one-shot - The command is a short shell-script that runs every test executable in turn,
stopping on the first failure with an error code (the
-eflag)
If we need additional services for testing
(e.g. mailhog for testing emails) we
would declare them here and add them in the links section so they get started
when we run docker-compose run
This gives us a neat description of the environment our tests should run in. To start the tests all we need to do is
- Ensure that test executabes are built and availabe in
dist/tests - run
docker-compose -f docker-compose.testing.yaml run --rm test
This will start the linked services and then run the test script.
Let's add it to our Makefile:
.PHONY: test
test: $(tests)
docker-compose -f docker-compose.testing.yaml run --rm test
docker-compose -f docker-compose.testing.yaml downRunning tests is now as simple as make test; the project will be built,
necessary services be started and finally the built tests are run. We leave the
database up and running after the tests are done, which improves startup time un subsequent tests and enables debugging, in case we need to go dig through the database after a test
Let's also amend the clean target to remove containers and volumes
.PHONY: clean
clean:
docker-compose -f docker-compose.testing.yaml down -v
...To run the tests, simply make test:
# make test
[... happy compiler noises]
/users
accepts a POST request
Finished in 0.0041 seconds
1 examples, 0 failures
[...]
Let's also test GETing from the /users resource. To accomplish that, we add another case:
specs = do
describe "/users" $ do
-- [...]
it "GETs the user" $ do
postJ "/users"
[json|{ "name": "Robert"
, "points": 33
, "group": "development"
}
|] `shouldRespondWith` 201
users <- get "/users" `shouldReturnA` (Proxy @NC.JSON)
users `NC.shouldBe` [json|[{ "name": "Robert"
, "points": 33
, "group": "development"
}]
|]- Note that the new test case starts from an empty database again, so we have to start by creating the user before we can fetch it. This helps ensure that the test cases are reproducible.
- We could factor out the code to create a new users, especially if we use it more often. It's e good idea to have it spelled out explicitly when it is the focus of the test (as it was in the first test case), but as part of the setup routine it adds a lot of noise.
- The
getaction fetches data from a resource. - We use
shouldResturnAto assert a successful HTTP status code (200-299) and that the response body can be parsed as JSON. It will then return the parsed result. - We pass a
Proxyvalue toshouldReturnAto indicate which type it should parse as. @NC.JSONis a "visible type application". It is another way of writingProxy :: Proxy NC.JSONNC.JSONmeans that we want the result as (structural) JSON, but not parsed any further. It is a newtype wrapper around Aeson'sValuetype.- We use the
jsonquasi-quoter again to write out the json we expect as result NC.shouldBeto assert that the response is equal. The two json values are compared structurally (because that's howEqis implemented on theJSONtype). If the equality fails, both values are printed as json and differences are highlighted.
Let's run this test:
# make test
[...]
/users
accepts a POST request
GETs the user
Finished in 0.0053 seconds
2 examples, 0 failures
[...]
Let's see what happens when we intentionally mess up a test. For example, say we changed the second test.
it "GETs the user" $ do
postJ "/users"
[json|{ "name": "Robert"
, "points": 33
, "group": "development"
}
|] `shouldRespondWith` 201
users <- get "/users" `shouldReturnA` (Proxy @NC.JSON)
users `NC.shouldBe` [json|[{ "name": "Peter"
, "points": 33
, "group": "development"
}]
|]Note how we have changed Robert to Peter
# make test
users
accepts a POST request
[Debug#SQL] SET client_min_messages TO ERROR;
SET CONSTRAINTS ALL DEFERRED;
DO $$
DECLARE
statements CURSOR FOR
SELECT tablename FROM pg_tables
WHERE schemaname = 'public';
BEGIN
FOR stmt IN statements LOOP
EXECUTE 'DELETE FROM ' || quote_ident(stmt.tablename)
|| ';';
END LOOP;
END;
$$;
RESET client_min_messages;
; []
[Debug#SQL] SET TRANSACTION ISOLATION LEVEL READ COMMITTED; []
[Debug#SQL] INSERT INTO "user"("name","points","group") VALUES(?,?,?) RETURNING "id"; [PersistText "Robert",PersistInt64 33,PersistText "development"]
[Debug#SQL] SET TRANSACTION ISOLATION LEVEL READ COMMITTED; []
[Debug#SQL] SELECT "id", "name", "points", "group" FROM "user"; []
GETs the user FAILED [1]
Failures:
test-suite/Main.hs:37:5:
1) /users GETs the user
expected: [{"points":33,"group":"development","name":"Peter"}]
but got: [{"points":33,"group":"development","name":"Robert"}]
As you see, we get a hole bunch of stuffdata dumped into our lap, let's look through it:
- The first test is still passing
- Next we get a dump of the logs, which are hidden for passing tests. In our case, the logs consist of all the SQL statements that were run.
- Finally, we get a report of the failure. It includes an explanation of what went wrong, in our case it tells us that the response we got differs from the assertion, which is exactly what we hoped to see
- Note that the json you see is parsed and re-serialized, so it might be slightly different from what the server actually produced, but it should be structurally equivalent