2024-11-18Baby unit tests

I once heard Dave Rupert call types "baby unit tests" and that never really left my mind.

I've never really enjoyed writing code in typed languages! The best I can say about them is that "it's fine, I guess" but the general feeling I have is that types mostly get in the way of my productivity. Instead of just doing what I want to do, I'm stuck either doing plumbing work to hold my values and pass them around, or I'm casting things back and forth where I know things are correct but the compiler doesn't. That, to me, is the dullest part of programming: knowing where you want to end up, but having to take a long detour there by solving the string of weird sudoku puzzles the compiler presents you with. Very often, it's ceremony for the sake of ceremony. I'd rather just pinky-swear that something is an integer or whatever and get on with my life.

Pointers are real and they will hurt you

I'm aware that L2 caches and pointers and memory allocation and heaps are things that exist in hardware and can't be wished away. I'm thankful for the fact that there are people in the world who actually enjoy things like Rust and its extraordinarily anal-retentive compiler so that I can have solid computing foundations to fuck around and find out on. But I'm not doing systems work, I'm doing web dev in a scripting language, so the incentives are wildly different—one is about maximum correctness at all times, the other is about iteration speed and fast feedback loops. And tests (including baby unit tests) can certainly improve those, to a certain point. I feel like I've found a nice local maximum in Ruby where I have a comprehensive test suite slanted towards controller tests that exercise most of my stack, but these last two years I've also changed how I declare my functions.

Naming is important

See, the trick is to use keyword arguments everywhere. If you're not familiar with Ruby syntax, you can pass arguments to functions like this:

def update_email(user, email)
  user.update(email: email)
end

update_email(my_user, "test@example.com")

And that's how we used to do it. It's very succinct and I suppose its primary advantage is that it's very readable and saves a bunch of keystrokes when you call the function. But if you, for some reason, decided you wanted to change the email on the Login class instead of the User class, you'd probably update the function:

def update_email(login, email)
  login.update(email: email)
end

update_email(my_login, "test@example.com")

This would be the point where you slap yourself on the forehead and curse your entire bloodline for not having written your app in Java, because now you have to find every instance of update_email in your project and change the user to a login, and unless you've been really diligent with your tests you could be looking at a completely silent failure. Remember: if you mess up and miss a single one, you're going to get nasty emails from your users calling your company "unreliable" and anyone personally involved a "son of a whore," and then your boss will call you and passive-aggressively ask "how this can be prevented in future releases" and your day will be ruined.

But what if you had used a keyword argument instead?

def update_email(user:, email:)
  user.update(email: email)
end

update_email(user: my_user, email: "test@example.com")

When you update this function to take login: instead of user: your code is going to start complaining, saying "no, you can't pass user: to this function, you need to pass login:." You know, kind of like a typed program would! These are even babier unit tests, but a kind of baby unit tests nonetheless. Keyword arguments confer like 75% of the advantages of typing (including perfectly understandable autocomplete using the Ruby LSP) with none of the const struct interface returns { this or that }:ResultValue<Blah|Moo|Booger> bullshit everywhere that you'd otherwise have to endure. If I'm in a scripting environment, I'm going to damn well enjoy being in a scripting environment, thank you very much! The Garbage Collector shouldn't have to do all its work in vain.