Testing Postgres Constraints with pgTAP
Modern day applications need to persist their state with a database. If you haven't jumped on the nosql database train, you probably utilize a relational database such as Postgresql. Today we will be taking a look at the importance of database constraints and how to test them using pgTAP.
Why you should use constraints
Your database is the source of truth for all data at any point in time and with constraints you can set up guardrails to protect that data. Constraints assist with ensuring the state of your data never invalidates your business logic. Imagine you're developing booking software for a hotel and a requirement is to not allow a room to be double booked on the same day. You can use constraints to ensure such conflicts will not arise.
Why you should test your constraints
Now that you understand why we need constraints, why should we test them?
Because that's what developers do! We write unit tests if we want confidence in
our code, therefore we should write tests if we want confidence in our schema.
As our constraints become more complex, it becomes even more important to write
tests for their behavior. This can be done with <insert-favourite-language>
,
however that process is more involved and is likely to be slower than pgTAP.
How to test your constraints
In this example we will be writing a constraint to prevent the double booking example discussed above. We will also be using Docker and Docker-Compose so you don't have to worry about installing some new software (unless you don't have docker yet of course).
If you don't want to follow along, you can pull this Github repo and just follow the steps in the README.
Initialize Docker-Compose Project
Create new project directory
mkdir test_pgtap_constraints
cd test_pgtap_constraints
touch docker-compose.yml
Create docker-compose.yml
version: '3'
services:
db:
image: postgres:11.4-alpine
environment:
- POSTGRES_USER=test_pg_tap
- POSTGRES_PASSWORD=supersecret
- PGPASSWORD=supersecret
volumes:
- ./pg-data:/var/lib/postgresql/data
pgtap:
image: hbpmip/pgtap:1.0.0-2
environment:
- DATABASE=awesome_hotel_booking
- USER=test_pg_tap
- PASSWORD=supersecret
depends_on:
- db
volumes:
- ./pgtap:/test
Here we are using two images:
Start the postgres database server
# test_pgtap_constraints/
docker-compose up -d db
Initialize Database
Enter the psql
console
# test_pgtap_constraints/
docker-compose run db psql -h db -U test_pg_tap
CREATE DATABASE awesome_hotel_booking;
\c awesome_hotel_booking
CREATE TABLE bookings (
id bigint NOT NULL,
room_number bigint NOT NULL,
date date NOT NULL,
name character varying NOT NULL
);
Write pgTAP Tests
Within the test_pgtap_constraints
directory, create and enter a pgtap
directory.
# test_pgtap_constraints/
mkdir pgtap
cd pgtap
Create bookings.sql
.
BEGIN;
SELECT plan(8);
SELECT has_table('bookings');
SELECT col_not_null('bookings', 'id');
SELECT col_not_null('bookings', 'room_number');
SELECT col_not_null('bookings', 'date');
SELECT col_not_null('bookings', 'name');
PREPARE insert_310_july_4_booking AS INSERT INTO bookings (
id, room_number, date, name
) VALUES (1, 310, '2019-07-04', 'Kevin Hart');
SELECT lives_ok(
'insert_310_july_4_booking',
'can insert booking with all attributes'
);
PREPARE insert_conflict_booking AS INSERT INTO bookings (
id, room_number, date, name
) VALUES (2, 310, '2019-07-04', 'Dave Chappelle');
SELECT throws_ilike(
'insert_conflict_booking',
'duplicate key value violates unique constraint%',
'do not allow two bookings for the same room on the same date'
);
PREPARE insert_814_july_4_booking AS INSERT INTO bookings (
id, room_number, date, name
) VALUES (3, 814, '2019-07-04', 'Tina Fey');
SELECT lives_ok(
'insert_814_july_4_booking',
'can insert booking in another room on the same date'
);
SELECT * FROM finish();
ROLLBACK;
Let's review what we just wrote.
The whole test plan is wrapped in a transaction so all of the inserts are rollbacked after the test plan finishes.
SELECT plan(8)
tells pgTAP
that we're going to run 8 tests. It's how all pgTAP test files begin.
SELECT has_table('bookings')
ensures
that our schema has a bookings
table.
SELECT col_not_null('bookings', 'id')
ensures that our
bookings
table has an id
column that does not allow NULL
values.
SELECT lives_ok('insert_310_july_4_booking')
ensures that the prepared statement we wrote
executes without raising any error.
SELECT throws_ilike('insert_conflict_booking')
ensures that an error is thrown, when we execute our prepared statement. In
this case we want postgres to throw a duplicate data error because of the
conflict booking. This test case should fail since we have not created our
constraint yet.
SELECT * FROM finish()
tells pgTAP that our tests have completed. This is so it can output more
information about failures or alert of you discrepancies between the planned
number of tests and the number actually run.
Lets run our test bookings.sql
through pgTAP and see the results. Since we
have not created our constraint yet, we are expecting the throws_like
test to
fail.
# test_pgtap_constraints/
docker-compose run pgtap
## OUTPUT
Running tests: /test/*.sql
/test/bookings.sql .. 1/8
# Failed test 7: "do not allow two bookings for the same room on the same date"
# no exception thrown
# Looks like you failed 1 test of 8
/test/bookings.sql .. Failed 1/8 subtests
Test Summary Report
-------------------
/test/bookings.sql (Wstat: 0 Tests: 8 Failed: 1)
Failed test: 7
Files=1, Tests=8, 0 wallclock secs ( 0.02 usr + 0.00 sys = 0.02 CPU)
Result: FAIL
The pgTAP output shows us that our double booking test is not passing. Let's add a unique constraint to get our test to pass.
Create Unique Constraint
Enter psql
console
# test_pgtap_constraints/
docker-compose run db psql -h db -U test_pg_tap -d awesome_hotel_booking
Add unique index
CREATE UNIQUE INDEX bookings_room_date_uq ON public.bookings (date, room_number);
Our constraint is actually a unique index that ensures there cannot be two booking records with the same date and room number. If this happens, postgres will throw the unique validation error
Rerun pgTAP Tests
# test_pgtap_constraints/
docker-compose run pg_tap
## OUTPUT
Running tests: /test/*.sql
/test/bookings.sql .. ok
All tests successful.
Files=1, Tests=8, 0 wallclock secs ( 0.02 usr + 0.00 sys = 0.02 CPU)
Result: PASS
Our tests are passing. Yay!
Project Cleanup
Stop postgres database server
# test_pgtap_constraints/
docker-compose down
Conclusion
Constraints are a useful way to ensure the integrity of our data. Once implemented, we can test and validate the behaviour of those constraints with pgTAP.