|
| 1 | +# Testo. Assert and Expect |
| 2 | + |
| 3 | + |
| 4 | + |
| 5 | +Let's talk about the pitfalls of reinventing the wheel that I've already stumbled upon while building a new testing framework [Testo](https://github.com/php-testo/testo). |
| 6 | + |
| 7 | +PHPUnit provides multiple ways to write the same assertions in tests: |
| 8 | + |
| 9 | +```php |
| 10 | +self::assertTrue(...); |
| 11 | +$this->assertTrue(...); |
| 12 | +assertTrue(...); |
| 13 | +``` |
| 14 | + |
| 15 | +All these calls lead to one place — the `Assert` facade with ~2300 lines. |
| 16 | + |
| 17 | +::: info 🤔 |
| 18 | +Did you know that PHPUnit has neither a standalone `expectException()` function nor a method with the same name in the `Assert` facade? |
| 19 | +In test code, you can only write `$this->expectException()`. |
| 20 | +::: |
| 21 | + |
| 22 | +That's because in PHPUnit tests inherit from `TestCase` (~2400 lines, extends `Assert`), which stores and handles all the test state. Reminds me of Symfony Console architecture, the worst I've encountered so far. |
| 23 | + |
| 24 | +How are test state and `assertException` related? The thing is, `expect` (expectation) differs slightly from `assert` (assertion) in both semantics and mechanics: |
| 25 | + |
| 26 | +- Assertions are checked here and now, "check and forget" style. |
| 27 | +- Expectations are checked later (after test completion), i.e., "remember now, check at the end". |
| 28 | + |
| 29 | +**Testo has a different policy.** |
| 30 | + |
| 31 | +From Testo's perspective, the test class belongs to the developer, not the framework. All meta-information and runtime data needed by the framework is stored and processed elsewhere. |
| 32 | + |
| 33 | +That's why test classes don't need to inherit from `TestCase`. In Testo, a test case doesn't run itself and doesn't even know its name in the test environment. This allows for cleaner code, and we can use the constructor however we want. |
| 34 | + |
| 35 | +Tests can even be plain user-defined functions! |
| 36 | + |
| 37 | +```php |
| 38 | +#[Test] |
| 39 | +function simpleTest(): void |
| 40 | +{ |
| 41 | + // test something |
| 42 | +} |
| 43 | +``` |
| 44 | + |
| 45 | +::: tip 🧠 |
| 46 | +But now we face a tricky question with lots of room for imagination: **how do we provide a convenient API for assertions?** |
| 47 | +::: |
| 48 | + |
| 49 | +Sure, we could eventually create a hundred functions, a trait, and a base class with PHPUnit-like syntax... But hey, let's try to find something better first! |
| 50 | + |
| 51 | +## Shorter and clearer |
| 52 | + |
| 53 | +I decided to start like the [webmozarts/assert](https://github.com/webmozarts/assert) library: since we no longer need to write `self::` or `$this->`, let's keep it simple: `Assert::same()`. I chose the familiar PHPUnit parameter order: **$expected** first, then **$actual** (webmozart puts the value being checked first, then the expected value, which actually **looks more logical**). |
| 54 | + |
| 55 | +Off we went. Made `::same()`, `::notSame`, `::null()`, `::true()`, `::false()`, `::equals()`, `::notEquals()`. |
| 56 | + |
| 57 | +Then we got to `Assert::greaterThan()`. In PHPUnit, the argument order is the same: **$expected** first, then **$actual**. |
| 58 | + |
| 59 | +So if we want to say `$foo is greater than 42`, we have to write `greaterThan(42, $foo)`. |
| 60 | + |
| 61 | + |
| 62 | + |
| 63 | +Looks disgusting, since everywhere else we use mathematical notation like `$foo > 42`. |
| 64 | + |
| 65 | +After some deliberation, the most understandable, short, and readable option won. |
| 66 | +Can you guess which one? |
| 67 | + |
| 68 | +```php |
| 69 | +Assert::compare($foo, '>', 42); |
| 70 | + |
| 71 | +Assert::satisfies($foo, '>', 42); |
| 72 | + |
| 73 | +Assert::that($foo)->greaterThan(42); |
| 74 | + |
| 75 | +Assert::true($foo > 42); |
| 76 | +``` |
| 77 | + |
| 78 | +::: warning ☝ |
| 79 | +This led us to a strategic decision: provide only "complex" assertions that save characters or entire lines of code. |
| 80 | +::: |
| 81 | + |
| 82 | +--- |
| 83 | + |
| 84 | +When it came to `expectException()`, the `Assert` facade started feeling uncomfortable. |
| 85 | +Initially it looked like `Assert::exception()`. The awkwardness stems from that difference mentioned at the beginning: the semantics (meaning) and mechanics of assertions (checking "here and now"). |
| 86 | + |
| 87 | +What are we asserting here? That we assert that we expect an exception to be thrown from the test? |
| 88 | + |
| 89 | +This bugged me for a long time. Lucky Bergmann with his inheritance — no need to think about naming semantics in facades — just shove everything into `$this` and problem solved. |
| 90 | + |
| 91 | +Eventually I came to the conclusion that we need a second facade `Expect`, which would provide post-check assertions. |
| 92 | + |
| 93 | +Pros: |
| 94 | + |
| 95 | +- Developers immediately recognize which check will be performed afterwards. |
| 96 | +- No naming dissonance. |
| 97 | + |
| 98 | +Cons: |
| 99 | + |
| 100 | +- No autocomplete from the `Assert` facade, and you need to remember about the second facade. Well, you just have to get used to it. |
| 101 | + |
| 102 | + |
| 103 | + |
| 104 | +--- |
| 105 | + |
| 106 | +## Trying something new |
| 107 | + |
| 108 | +Instead of adding tons of sugar like `::stringContains()`, `::stringEndsWith()` (and 20 more `string*` methods) into one facade, we can group methods by semantics or type: |
| 109 | + |
| 110 | +```php |
| 111 | +// Strings |
| 112 | +Assert::string($string)->contains("str"); |
| 113 | + |
| 114 | +// Files |
| 115 | +Assert::file("foo.txt")->notExists(); |
| 116 | + |
| 117 | +// Exceptions |
| 118 | +Expect::exception(Failure::class) |
| 119 | + ->fromMethod(Service::class, 'process') |
| 120 | + ->withMessage("foo bar"); |
| 121 | +``` |
| 122 | + |
| 123 | +This way, at the start of the pipe in `Assert::string()`, we immediately verify that we're actually receiving a string, and in `->contains(...)` we perform the check already confident we're working with the right type. |
| 124 | + |
| 125 | +Code takes less space, facades aren't bloated. Now this is what looks truly elegant. Whether it's usable or not — practice will tell. |
| 126 | + |
| 127 | + |
| 128 | + |
| 129 | +--- |
| 130 | + |
| 131 | +So we made several of these pipe assertions. |
| 132 | + |
| 133 | +```php |
| 134 | +#[Test] |
| 135 | +public function checkIterableTraitMethods(): void |
| 136 | +{ |
| 137 | + Assert::instanceOf(\DateTimeInterface::class, new \DateTimeImmutable()); |
| 138 | + // Shorthand for Assert::object($object)->instanceOf($class); |
| 139 | + |
| 140 | + Assert::int(15)->greaterThan(10); |
| 141 | + |
| 142 | + Assert::array([1,2,3])->allOf('int')->contains(3)->hasKeys(0)->sameSizeAs([4,5,6,7]); |
| 143 | +} |
| 144 | +``` |
| 145 | + |
| 146 | +Can we turn this into useful output? |
| 147 | + |
| 148 | +In Testo, I planned for assertion logging per test from the start. This mechanism had to be reworked a bit for composites after pipe assertions appeared, but that's beside the point. |
| 149 | + |
| 150 | +Let's try displaying the assertion list and see what comes out of it. |
| 151 | + |
| 152 | +**Compact variant.** I like it, but it looks a bit rough and might not appeal to those who dislike abbreviations over language constructs. |
| 153 | + |
| 154 | + |
| 155 | + |
| 156 | +**Fuller variant.** Here all checks in the pipe are listed separated by semicolons. Reads like a book, and the nested tree element contains the full exception text. |
| 157 | + |
| 158 | + |
| 159 | + |
| 160 | +How to improve all this — unclear for now. Maybe go back to compact? |
| 161 | + |
| 162 | +We're also trying out how it would look in an IDE. Will this be useful? |
| 163 | + |
| 164 | + |
| 165 | + |
| 166 | +There's also an option to output each assertion as a nested checkmark in the test tree (like DataSet), but I think that would be too cluttered. |
| 167 | + |
| 168 | +::: tip ☝ |
| 169 | +It might seem like there are only more open questions. But over time, not only questions appear, but expertise grows too: each answer to a closed question is backed by mental or practical experience. |
| 170 | +::: |
| 171 | + |
| 172 | +The final word on assert/expect hasn't been said. But while Testo hasn't reached a stable release, we can afford any experiments. |
| 173 | + |
| 174 | +I won't be surprised if in the future we decide that **$expected** should come after **$actual**, that pipe assertions aren't as convenient and we need functions, and that assertion history output is overkill. |
| 175 | + |
| 176 | +--- |
| 177 | + |
| 178 | +[Join](https://t.me/spiralphp/10863) the discussion or development, propose your wildest ideas or features. It's interesting. |
| 179 | + |
| 180 | +Special thanks to: |
| 181 | + |
| 182 | +- [@petrdobr](https://github.com/petrdobr) for help with assertion implementation. |
| 183 | +- [@xepozz](https://github.com/xepozz/) for the [IDE plugin](https://plugins.jetbrains.com/plugin/28842-testo), which needs your stars. |
0 commit comments