Legacy codebase: adding tests and checks or progress is better than perfection
When dealing with legacy codebases (as defined in Working Effectively With Legacy Code by Michael Feathers), ie. ones without tests or checks, it might feel overwhelming to start. In this blog post I'll try and outline my method for dealing with this.
My main motto here, as the title might suggest, is "progress over perfection". While it might be tempting to try and hit 100% code coverage straight away, as well as cover all the supported use-cases. This is a fool's errand. You will spend incredible amounts of time designing the test suit and mapping the whole repository. On large code bases, this might not even be feasible, as others are likely working on it. So before you can map the whole codebase and it's use-cases, things will have changed and previous facts might no longer hold true.
Try and start as small as possible. This boils down to just setting up the testing or whichever check tool you choose - as this also relates to any sort linter, formatter or any other automated tool to enforce the way your repository looks and behaves (you are storing your code in some versioning tool, right?).
If you're working with a team, try and get your changes integrated into whatever artifacts you're producing and running. This will ensure your changes get tested often and the scope is manageable for your team as well. It will also help and keep them up-to-speed on your efforts, as if they know it's happening, they're more likely to contribute.
For tests, this might be just a single test file, possibly empty or validating some trivial check (eg. the module can be imported - you don't even need a testing framework for this, as simple shell script invoking your program is enough). Obviously, how trivial the check is depends entirely on the scope and "legacy-ness" of the codebase. If it's a simple-enough script, you're likely capable of covering the core business logic in couple of hours at the worst case, perhaps even minutes. Unfortunately, for some tools like code formatters, if the repository in a very state than the formatter would have it a large "single swath" commit is likely necessary, so some breakage in the actual logic is possible, depending on your language and such. Luckily enough, this just creates a natural prioritization to the tools you should be enrolling. Tests are likely to come up first, using plentiful mocking to cover the core logic. Afterwards linters (as unlike formatters, these are likely to catch logic errors - CITATION NEEDED) and finally formatters can come.
Actually explaining what linters and formatters are might have also been nice, but I assume the reader has some semblance of the terms.
So my simple algorithm - for the testing - (TODO: rewrite to number list?) is. Start at the entry-point of your service - or script/batch job/whatever, whichever part is the thing being run. Skip all the "stuff" from libraries setting up config, database or anything else, just get to the first part that starts doing actual "business logic". Mock EVERYTHING that has any other dependency - you might need to refactor the main entry-point if it's a long spaghetti, just start at the top and extract variables and functions initializing them. Choose the most trivial test-case, essentially a no-op - it starts from your test code and completes without error/exception. Afterwards look at what you've had to mock. Those will be the next things you'll need to cover with new test cases, using the same general framework described above.
And that is essentially it! It might seem like ceaseless effort without end, but what's not?