---
title: "There and Back Again: Round-Trip Testing in PHP"
url: https://www.exakat.io/there-and-back-again-round-trip-testing-in-php/
date: 2026-07-23
modified: 2026-07-23
author: "dams"
description: "There and Back Again: Round-Trip Testing in PHP Most of the work in a unit test is not calling the function. It is knowing what the function is supposed to..."
categories:
  - "Code auditing"
tags:
  - "test"
image: https://www.exakat.io/wp-content/uploads/2026/07/globe.320.jpg
word_count: 1404
---

# There and Back Again: Round-Trip Testing in PHP

# There and Back Again: Round-Trip Testing in PHP

Most of the work in a unit test is not calling the function. It is knowing what the function is supposed to return. You call it, you get a value, and now you type the expected value by hand and pray you did the arithmetic in your head correctly. Get that wrong and the test is worse than no test at all. It fails on good code and passes on bad code, which is precisely the opposite of the job.

There is a family of functions where you can skip that step entirely. If a function has an opposite, some operation that undoes it, you do not need to know the output at all. You only need to know that doing the thing and then undoing it lands you back where you started.

That is round-trip testing. Write the function, write its undo, run them back to back, assert you are home.

## The pattern

Take any reversible pair. Encode a string, decode it, check nothing moved:

Look at what is not in that test. There is no expected value. I never worked out what `base64_encode`produces, and frankly I would rather not. The correctness question moved from "did I compute the right answer" to "do these two functions agree." The second question is much easier to answer, and much harder to get subtly wrong.

## You needed the opposite anyway

This is the part people undervalue. Real code is full of operations that already come in pairs, because the application demands both directions. You serialize and you unserialize. You encode to JSON and you read it back. You run a migration and, on a bad day, you roll it back.

Both directions had to exist. The round-trip test is nearly free. You are checking two functions you were going to ship regardless, and the checking falls out for nothing.

## Throw random data at it

Because you never write the expected value, you can feed the test anything. That makes it a natural fit for property-based testing, where a library generates hundreds of random inputs and checks the property holds for all of them. In PHP that is Eris:

One small test, and the generator goes hunting for the input that breaks you: the empty string, the raw null byte, the emoji you forgot existed. This is also the one honest use of randomness in the whole exercise. The random value is not the thing under test. It is the thing you test with. Generation has no opposite, so it feeds the round trip rather than sitting inside it.

## What, exactly, is the opposite?

Here is where the idea gets more interesting than "one function, one inverse." The opposite is rarely that tidy.

Sometimes the opposite is the same function again. `array_reverse` undoes `array_reverse`. `str_rot13` undoes `str_rot13`. Apply it twice and you are back:

`str_rot13` is the only cipher that ships its own decryption for free. It is also the only cipher you must never use for anything, so enjoy it responsibly.

Sometimes there are several opposites, and you must pick one. Turning a string back into a date is the classic case. PHP will happily do it through `DateTimeImmutable::createFromFormat`, through `strtotime`, or through the `date_create` family. They do not always agree about your input, and at least one of them will read your birthday differently from the others. Whichever you choose, the opposite has to match the options you used on the way out:

Notice the assertion compares the formatted strings, not the objects. The format string dropped the timezone, so the parsed object is not the original object. It cannot be. You compare what survived the trip, which is a weaker but honest claim.

Sometimes the options are the whole story. `htmlspecialchars` takes flags, and the opposite only works when it uses the same ones:

Drop `ENT_QUOTES` on one side and the round trip quietly lies to you. Both calls still run. They just no longer describe the same operation.

And sometimes the opposite is not an inverse at all. It is a verifier. You cannot un-hash a password, and if you can, you are not writing a blog post, you are writing a security disclosure. `password_hash` has no inverse by design. The useful opposite is `password_verify`, which hands back a boolean instead of the original:

Two things worth noting. The round trip returns true, not the password. And `password_hash` is deliberately non-deterministic, so hashing the same input twice gives two different hashes. A naive "hash it, hash it again, compare the hashes" test would fail on perfectly correct code. The opposite you want is the one that answers the question the code actually asks: does this password match.

## The trap: two wrongs that pass

A round-trip test proves the two functions agree with each other. It does not prove either one is right on its own. If both directions share the same mistake, the errors cancel and the test goes green.

Encode with the wrong flag, decode with the same wrong flag, and the round trip is spotless. The day some other program reads your output, it falls apart, and your suite insists everything is fine. Two wrongs do not make a right, except in a round-trip test, where they make a passing one.

The fix is cheap. Pin one side down with a plain old example-based test now and then. Feed a known input, assert on the exact known output. Round trips cover the space for almost nothing. A few example tests keep the pair anchored to reality, so it cannot drift off into a shared private convention that only the two of them understand.

## Where it does not apply

The technique needs a real opposite, and plenty of operations refuse to hand one over.

Some are irreversible on purpose. `random_bytes` has no undo, and that is the entire point of it. A reversible random generator is not a feature, it is a bug with a CVE number. Hashing destroys information. Deletion deletes. There is nothing to write on the other side.

Some are lossy. `strtolower` cannot give you back the original casing. A date format that omits the timezone cannot reconstruct it. Rounding rounds. Here you can still get something, by testing for stability instead of reversal. Once the value has been through the lossy step, running it again should change nothing:

That is a weaker property than a true round trip, but it still catches the day someone breaks `strtolower`, and it costs one line.

Some opposites are simply expensive. If the undo does not already exist and would take real work to build, you are now writing and maintaining production-grade code for the sole purpose of testing. Sometimes a handful of example tests is honestly cheaper.

And sometimes the project shortened one direction on purpose. Teams optimise the path they use and stub the path they do not. Rebuilding a faithful opposite just to test the forward path means putting back the complexity someone deliberately removed. That is not testing, that is archaeology.

## Rule of thumb

Reach for round-trip tests when the opposite already exists or has to exist anyway, and lean on Eris to shove random inputs through both directions. Keep a couple of example-based tests around so symmetric bugs cannot hide. When there is no natural opposite, or building one costs more than it saves, do not force it. That is what example tests are for.

The good version of this is not a clever trick you bolt on. It is noticing that a surprising amount of PHP already ships in matched pairs, and letting each half keep the other one honest.