Programming 101

Part of Read before contributing, an opinionated guide by William Reade

Programming 101

Practice this stuff consciously until you’re doing it unconsciously. It transcends languages and will serve you well wherever you go.

Do Not Use Global Variables.

Seriously, please, DO NOT USE GLOBAL VARIABLES.

Do not even use one little unexported package-global variable, unless you have explored the issue in detail with a technical lead and determined that it’s the least harmful approach.

One might think that one would not need to stress this in a group of professional software developers. One would be heartbreakingly wrong; so, for those who have not seen the light:

Our most fundamental limitation as software developers is in the number of things we can consider simultaneously. We all know how hard it is to deal with a func that takes 7 params – that’s a lot to think about at once – but it’s far superior to a func that takes 5 params and uses 2 global variables, because the dependencies have been made explicit and the code has been decoupled from the rolling maelstrom of secret collaborators – i.e. everything else that might ever read or write that global variable.

Extracting global usage from existing code pays off handsomely and fast, and opportunities to do so should pretty much always be taken. You never have to fix the whole program at once: you just drop the global
reference, supply it as a parameter from the originating context, and move on. It doesn’t matter if there’s now a new global reference one level up: it’s one step closer to the edge of the system, and one step closer to being constructed explicitly, just once, and handed explicitly to everything that needs it.

(Aside: environment vars are global variables too. If you need some config from the environment, read it as early as possible and hand it around explicitly like anything else.)

Write Unit Tests

And that means you should write unit tests: tests for each unit of functionality that will interact with other parts of the program.

Write integration tests and functional tests as well, for sure; but they’re never going to cover every possible weird race condition. Your unit tests are responsible for that; you use them to imagine and induce every situation you can imagine affecting your component, and you make every effort to ensure it behaves sensibly in all circumstances.

If you think it’s hard to test actual units, your units are too big. If you want to write internal tests, your units are too big. (Or possibly, in either case, you’re screwing about with globals. If so, stop it.)

The underlying insight is: tests exist to fail. If a test has never failed, it has value only in potentia; and its value is only realised, for better or for worse, when it does fail. If the failure uncovers a real problem, it delivers actual value; but every spurious failure contributes to a drip-feed of negative value.

(Oh, and spurious successes are a vast flood of negative value: they build up slowly and silently until someone notices you’ve been shipping a broken feature for 6 months, at which point you suddenly have to book all that negative value at once and scramble to stay afloat.)

But, regardless: as discussed, every test is a cost and a risk. Any test that does not cleanly and clearly map to a failure of the unit to meet expectations is especially risky, because it comes with a large extra analysis cost every time it fails. So: be thoughtful about the tests you do write, and make each test case as lean and focused as possible.

Ultimately, your tests will be judged by their failures, and by how easily others can manipulate them to change or add to the SUT’s behavioural constraints. Behaviour behaviour behaviour. Behaviour.

Test Behaviour, Not Implementation

This means, at a minimum:

  • do not write tests in package foo, write them in package foo_test
  • do not use the export_test.go mechanism at all
  • do not come up with any other scheme for touching unexported bits
  • do not export something just to test it, unless you give clients the
    exact same degree of control that your tests have and require them
    to exercise it
    .

…but if you’re creative you’ll find other ways to make this mistake. Regardless, by forbidding internal access; not using globals (especially not global func vars, they’re at least as bad as any other global); and not privileging either the tests or the runtime clients, we can write a set of executable specifications for how a component will behave under various circumstances. This is the single most valuable thing you can have when refactoring or debugging.

  • When refactoring, it’s valuable because it allows you to actually refactor, i.e. change the code without changing the tests, and have a reasonable degree of confidence in the result.

  • When debugging, it’s valuable because the existence of good tests makes it easy to write more good tests: if you’re changing behaviour (which you will have to do in response to bugs) you already have a framework designed to express the component’s possible interactions with all its collaborators.

Some developers maintain that internal tests deliver value, and it’s not impossible for them to do so; but they are a continual stumbling-block for future refactoring efforts, and they are not an adequate replacement for any behaviour test.

(And their tendency to keep passing when the component as a whole is failing to meet the responsibility that the tests appear to be validating is poisonous. If you don’t think this is a big deal, I envy your innocence.)

2 Likes

Martin elaborates on Testing Behavior Not Implementation with respect to stubs, spys, and mocks:

https://8thlight.com/blog/uncle-bob/2014/05/14/TheLittleMocker.html