Property Testing A Database Driver

2 minute read

A database driver is responsible for orchestrating the protocol between an application programming language and a database. Using Erlang/OTP and PostgreSQL, this article features writing property tests with PropEr to efficiently verify a driver implementation.

For example, PostgreSQL has smallint which is represented by a 16 bit signed integer. Using -1 as an example value, with a “traditional” non property based test:

smallint_test() ->
  ?assertEqual(-1, pbe("select $1::smallint", [-1])).

Where pbe is a function that performs the actions of parse, bind and execute. PostgreSQL will parse select $1::smallint and bind the parameter $1 with -1. When we execute this statement we assert that the database responds with -1. This mechanism enables us to test the serialisation and deserialisation of each type.

As a signed 16 bit integer, the range of values for smallint are between -32,768 and 32,767. Using non property based testing methods, we might write further tests that check that -32,768, …, -1, 0, 1, …, 32,767 are correctly represented:

smallint_test() ->
  case for -32,768,
  case for -1,
  case for 0,
  case for 1,
  case for 32,767

There are dozens of other PostgreSQL types that also need testing. Maintaining the test data and verifying its correctness rapidly becomes a maintenance overhead.

Instead, with property based testing we define a “generator” for our test inputs. For smallint such a generator would be integer(-32_768, 32_767). The tool uses this generator to create valid test input, resulting in a property based test:

smallint_test() ->
    integer(-32_768, 32_767),
      Result = pbe("select $1::smallint", [I]),
      Result == I

The above says that ?FORALL integers (called I) in the range of -32,768..32,767, when executing select $1::smallint with I we expect the Result to be identical to I.

We let the property based testing tool generate our test values using the model that we have supplied. The tool can be configured to generate as many test cases as we want using this model.

In PostgreSQL floating point numbers can be represented by the real or double precision which are inexact, variable-precision numeric types. The property test generator float(inf, inf) will generate floating point numbers between -∞ and ∞. However, real has a precision of just 6 decimal digits, whereas double precision has 15. Rounding might take place if the precision of an input number is too high. We must ensure that the returned value is correct to the right level of precision for the type that we are testing.

We can refine the generator using ?LET for real with 6 decimal digits of precision:

?LET(F, float(inf, inf), precision(F, 6))

A value from the generator of float(inf, inf) will be represented by the variable F. The call to precision(F, 6) ensure that F has the appropriate number of decimal digits for the type.

Our test for the real now looks like:

real_test() ->
    ?LET(F, float(inf, inf), precision(F, 6)),
      Result = pbe("select $1::real", [Real]),
      Result == Real

Further more complex examples can be found here, including PostgreSQL types of point, polygon, path, line segment, circle, box, line.