Stay on top of your testing

In a small project, only a few thousand lines, it’s easy to fix a bug without causing any more bugs because you know the system in and out.

Once you start approaching 10,000 lines, fixing one bug will often cause multiple other bugs. Not just easy bugs, but obscure bugs that your users find six months later.

However, with tests, you’ll know instantly whether your fix caused any other bugs. You can then try a better fix.

If you don’t have a vast suite of tests, your project might get slower and slower until changing anything feels like pulling teeth. 8

Some languages are easier to test with. Javascript’s Monkey Patching is a wonderful alternative to mocking, and can make testing much easier. 9

Prefer end-to-end tests

In the early days of the Vale compiler, we had a lot of unit tests for our various components.

For those unfamiliar, a unit test is one that specifically tests just one piece of code. You craft some inputs, feed those into your code, and check the outputs.

Unit tests are nice because they tell you exactly where the bug is, because they test only a small piece of your code.

However, the data being passed between these components was changing very often, because the project was evolving rapidly in response to user feedback and experiments.

And unfortunately, every time this happened, we had to change the unit tests. Quite irksome!

Instead, we’ve switched over to end-to-end tests. An end-to-end test is where a script will open up your application and click on buttons and type inputs in the right sequence to indirectly run some specific code in your program.

For the Vale compiler, it means we run the compiler with some Vale source code, then run it, and make sure it produces the right output. As of this writing, the Vale compiler has 1,308 end-to-end tests.

Some caveats on this advice:

  • It not apply to larger endeavors, only ones that have rapidly shifting internals, as new projects often do. As a project becomes larger, other priorities dominate.
  • If there’s a piece of the program whose code changes often but the inputs and outputs are relatively stable, unit tests are still a good choice.

Know when to add a test

It’s good practice to add a test whenever you stumble upon a bug.

However, let’s take that advice one step further.

Whenever you have a test that discovers a bug, ask yourself, “could a more specific test have caught this too?” and then add that more specific test.

This approach has a hidden benefit. If you’re refactoring a nearby area of your code and you break this functionality, you now have a much more specific test failure to tell you what exactly is going wrong.

Prioritize development velocity

If you’re not careful, your development speed can slow to a crawl. Here are some ways to keep yourself nimble:

Use a language with good compile speeds. If you go doomscrolling on reddit while you’re building, you know your compile times are too long.

Use a memory-safe language. Memory safety doesn’t just help with security, it helps you avoid bugs that are very difficult to diagnose.

Prioritize looser coupling. If you have to change code way over there to accommodate a feature way over here, take a step back.

Find a way to harness object-oriented benefits:

  • Dependency injection (the pattern, not the kind of framework)
  • Encapsulation
  • Polymorphism

…without incurring object-oriented drawbacks (implementation inheritance’s brittleness).

Use a flexible language. The best languages let you focus on the problem you’re trying to solve, rather than the constraints of the language which don’t make much sense for your use cases.

For example, if you’re making a turn-based roguelike game, C# or Typescript could be better choices than Haskell or Rust, whose extra constraints might cause extra refactoring and complexity. 10

Statically-typed, garbage collected languages like Java 11 can sometimes be the best in this regard. They may not be flashy but they’re flexible, much more multi-paradigm, and have good compile speeds.

Bonus points to languages like Scala that let you temporarily “turn off” the type system via Nothing, so you can work on your feature now and fix the types for unrelated code afterward.

Bonus: Sanity Checking

Let’s take assertions to the next level!

Let’s say you have a id_to_account HashMap. Unfortunately, to find a user by name, you have to loop through the entire map, because it’s keyed by ID, not by name.

So then you add a separate name_to_account HashMap, and you try to keep these two maps in sync. However, if you accidentally remove an account from only one, you now have a data inconsistency.

After you add your normal assertions, also consider periodically calling a sanityCheck function:

func sanityCheck(

id_to_account &HashMap<ID, UserAccount>,

name_to_account &HashMap<str, UserAccount>)
{

assert(id_to_account.size() == name_to_account_map.size());

assert(name_to_account.map(x => x.id) == id_to_account.keys());


// … and even more assertions!

}

In one case, we have an 80-line sanity check function to check that all the state in the generics solver is consistent.

When I worked on Earth, I made a 200-line sanityCheck function run before and after every click, which made sure the application was in a sane state. It saved countless hours of debugging.

Lean hard on this technique, it will serve you well.

That’s all!

Thanks for visiting, and I wish you the best of luck in your first 100,000 lines!

In the coming weeks, I’ll be posting the next article in the Implementing a New Memory Safety Approach series, so subscribe to our RSS feed, twitter, or the r/Vale subreddit, and come hang out in the Vale discord.

If you found this interesting or entertaining, please consider sponsoring me:


Sponsor me on GitHub!

With your help, I can write this kind of nonsense more often!

Read More