Recently, we wrote a Base58Check encoder to power our Bitcoin private key and public address generator. Being the diligent developers that we are, we added a unit test to ensure that our encoder was working as we expected.

But was that enough?

Call me a coward, but relying on a solitary unit test based on a single example pulled from a wiki article doesn’t instill huge amounts of confidence in our solution.

Let’s thoroughly test our solution with the help of property-based testing tools and an external oracle!

Oracles and Property Testing

The Base58Check encoding algorithm has been implemented many times by many different developers. Wouldn’t it be great if we could automatically check our implementation against theirs?

We can!

In property-based testing vernacular, this is known as using an oracle. An oracle is another implementation of your solution that is known to be correct under some domain of inputs.

Thankfully, we have a perfect oracle in the form of the Bitcoin Explorer’s CLI tools. Bitcoin Explorer ships with a base58check-encode utility that Base58Check encodes any Base16 string with a given version byte:


> bx base58check-encode abc123 --version 0
17WWM7GLKg9

Given this oracle, we can thoroughly and concisely test our implementation with a single property. The primary desired property of our solution is that it should match the output of bx base58check-encode for all valid inputs.

Getting Comfortable with our Tools

Property testing is simple in concept, but more difficult in practice.

It’s easy to say that for any given binary and any given byte, the output of our solution should match the output of my oracle. Actually generating those inputs and coordinating those test executions is a whole different ball game.

Thankfully, the groundwork has already been laid for us, and there are plenty of Elixir-based property testing tools for us to chose from. For this exercise, let’s use StreamData.


To get our feet wet, let’s write a simple property test using StreamData that verifies the associative property of the Kernel.+/2 addition function:


property "addition is associative" do
  check all a <- integer(),
            b <- integer(),
            c <- integer() do
    l = Kernel.+(Kernel.+(a, b), c)
    r = Kernel.+(a, Kernel.+(b, c))
    assert l == r
  end
end

The property keyword defines our new property test with a short description of the property under test.

The check all block lets us define our automatically generated inputs and a function block that will use those inputs to make assertions about our property.

Put simply, we’re telling StreamData that we want three random integers: a, b, and c. For every set of a, b, and c, we want to verify that (a + b) + c equals a + (b + c).

StreamData does this by generating many (one hundred by default) random sets of a, b, and c and checking them against our assertions. If any assertion fails, StreamData will try to “shrink” the input set (a, b, and c, in this case) to the simplest possible failing test case and present it to us.


> mix test
.

Finished in 0.06 seconds
1 property, 0 failures

Thankfully, addition is associative, and our test passes!

Consulting the Oracle

Now let’s take the training wheels off and write a property test for our Base58Check encoder against our external oracle.

First, we’ll define a new test block:


property "gives the same results as bx base58check-encode" do
end

Within our test, we’ll generate two random variables, key and version:


check all key <- binary(min_length: 1),
          version <- byte() do
end

We’re telling StreamData that key can be any non-empty binary, and that version can be any byte.

Now that we have our set of test data, we’ll need to get the result of encoding key with version using our own implementation of the Base58Check encoding algorithm:


result = Base58Check.encode(key, <<version>>)

Next, we’ll use Elixir’s System.cmd to call bx base58check-encode, passing in our Base16-encoded key string and our version byte:


oracle =
  System.cmd("bx", [
    "base58check-encode",
    Base.encode16(key),
    "--version",
    "#{version}"
  ])
  |> elem(0)
  |> String.trim()

Now all that’s left to do is to verify that our result matches the output of our oracle:


assert result == oracle

If StreamData detects any failures in this assertion, it will simplify key and version to the simplest failing case and report the failure to us.

But thankfully, our implementation of the Base58Check encoding algorithm passes the test:


mix test
.

Finished in 1.0 seconds
1 property, 0 failures

Final Thoughts

I won’t pretend to be a property testing expert. I’m just a guy who’s read a few articles and who’s hopped on board the hype train. That said, property testing was the perfect tool for this job, and I can see it being an incredibly useful tool in the future. I’m excited to incorporate it into my testing arsenal.

If you’re interested in property-based testing, I recommend you check out Fred Hebert’s PropEr Testing, and Hillel Wayne’s articles on hypothesis testing with oracle functions and property testing with contracts.

Lastly, if you’re interested in Bitcoin development, I encourage you to check out Andreas Antonopoulos’ Mastering Bitcoin.