DEV Community

Philipp Scheit
Philipp Scheit

Posted on

The Lazy Lurk: A Mental Model for Better Tests

You know how to write tests. Red, green, refactor. Assert this, mock that. But here's a question that might make you uncomfortable:

Could your tests pass with a completely broken implementation?

Let me show you what I mean.

The roundtrip trap

I recently saw this test for an encryption service:

public function test_encrypt_decrypt_roundtrip(): void
{
    $encrypted = $this->encrypter->encrypt('important data');

    $this->assertSame('important data', $this->encrypter->decrypt($encrypted));
}
Enter fullscreen mode Exit fullscreen mode

Looks reasonable, right? Encrypt something, decrypt it, verify you get the original back. Ship it.

But here's the thing: I can make this test pass in 10 seconds.

class Encrypter
{
    public function encrypt(string $data): string
    {
        return $data;
    }

    public function decrypt(string $data): string
    {
        return $data;
    }
}
Enter fullscreen mode Exit fullscreen mode

Test is green. ✅

No encryption happened. Your "important data" is stored in plain text. But the test passes, so everything's fine... right?

Meet the Lazy Lurk

Here's a mental model I use when writing tests:

Imagine the person implementing the code is a lazy lurk. They will do anything to make the test pass - even if it makes no sense.

Not malicious. Just lazy. They want green tests with minimum effort. Your job as the test writer is to make cheating impossible.

Every time you write a test, ask yourself: "What's the dumbest implementation that would pass this?"

If the answer is "do nothing" or "return a hardcoded value" - your test is incomplete.

Classic examples of tests the Lazy Lurk loves

The roundtrip test:

$this->assertSame($input, $serializer->deserialize($serializer->serialize($input)));
// Lazy implementation: return $input for both methods
Enter fullscreen mode Exit fullscreen mode

The single-case test:

$this->assertSame(4, $calculator->add(2, 2));
// Lazy implementation: return 4;
Enter fullscreen mode Exit fullscreen mode

The boolean trap:

$this->assertTrue($validator->isValid('test@example.com'));
// Lazy implementation: return true;
Enter fullscreen mode Exit fullscreen mode

The "it works" test:

$result = $service->process($data);
$this->assertNotNull($result);
// Lazy implementation: return new Result();
Enter fullscreen mode Exit fullscreen mode

How to defeat the Lazy Lurk

The fix is always the same: add another constraint that the lazy implementation can't satisfy.

For the encryption roundtrip:

public function test_encrypt_actually_encrypts(): void
{
    $plaintext = 'important data';

    $encrypted = $this->encrypter->encrypt($plaintext);

    // The Lazy Lurk can't cheat past this
    $this->assertNotSame($plaintext, $encrypted);

    // And we still verify the roundtrip
    $this->assertSame($plaintext, $this->encrypter->decrypt($encrypted));
}
Enter fullscreen mode Exit fullscreen mode

Now the no-op implementation fails. The Lazy Lurk actually has to encrypt something.

For the calculator:

public function test_add(): void
{
    $this->assertSame(4, $calculator->add(2, 2));
    $this->assertSame(7, $calculator->add(3, 4));  // Can't hardcode both!
    $this->assertSame(0, $calculator->add(0, 0));
}
Enter fullscreen mode Exit fullscreen mode

For the validator:

public function test_email_validation(): void
{
    $this->assertTrue($validator->isValid('test@example.com'));
    $this->assertFalse($validator->isValid('not-an-email'));  // Forces real logic
}
Enter fullscreen mode Exit fullscreen mode

The pattern

See what's happening? We're adding triangulation - multiple data points that force a real implementation.

One test case = hardcodeable.
Two contradicting cases = requires actual logic.

It's like GPS. One satellite tells you nothing useful. Two gives you a line. Three gives you a position.

The Lazy Lurk always wins (a little)

Here's the uncomfortable truth: the Lazy Lurk will always get away with something.

No matter how many test cases you add, there's always a gap. Tests can only prove the absence of known issues, not the absence of all bugs. That's not a failure of your tests - that's the nature of testing.

So it's up to you to stop the circle.

TDD is not one loop

Here's something I see developers get wrong all the time (and honestly, I catch myself doing it too): they think TDD is one cycle per feature.

Write test. Red. Write code. Green. Done.

But that's not how it works. One feature is many cycles. You write a test, it fails, you make it pass. Then you think: "Wait, what if the input is empty?" New test. Red. Green. "What about special characters?" Red. Green. "What if it's called twice?" Red. Green.

Each cycle, you're blocking another cheat. Each cycle, the Lazy Lurk has fewer places to hide.

Know when to stop

But here's the trap: you can circle forever.

What about null? What about unicode? What about concurrent access? What about leap years on a Tuesday during a full moon?

At some point, you need to stop. Too detailed tests become a burden:

  • They're brittle and break on every refactor
  • They test implementation details instead of behavior
  • They slow down your build
  • They make the next developer afraid to touch anything

The Lazy Lurk will get away with something. That's okay. Your job isn't to write perfect tests - it's to write useful tests that catch the important cheats.

Ship it. If the Lazy Lurk's shortcut causes a bug in production, you've just discovered your next test case.

Top comments (1)

Collapse
 
xwero profile image
david duymelinck • Edited

I think this is an example of doing TDD wrong.
When function or methods are named there is already some logic behind that name. So the question you should ask yourself is; what the dumbest logic that is needed to pass.

A round trip should be accompanied with a test that actually does the thing the function/method is supposed to do.

public function test_encrypt(): void
{
    $this->assertNotEquals('important data', $this->encrypter->encrypt('important data'));
}
Enter fullscreen mode Exit fullscreen mode

This test should be created first because encrypt is more important than encrypt.
This makes the round trip test less prone to code that is only added to fulfill the tests.

For the single case, boolean trap and it works cases, negative tests should keep that test in check.

The idea of TDD is not to write tests for the sake of tests, but to add tests as an integral part of the code and make them test cases as close to the actual use as possible. This last thing means it is possible you need to write multiple tests to be sure a method/function works as expected.