I wrote Unit Testing Old Code in January 2004, and I stand behind the recommendations. In short, use unit tests to accomplish what you are trying to do right now. Tests will help you refactor and optimize without breaking something, they will help you represent new learnings as you read code, and they are a good thing to write to reproduce a bug before you fix it. In all three cases, it’s a means to an end.
Later that year, Working Effectively with Legacy Code [affiliate link] came out with the definitive advice on how to add tests to a codebase without them—introducing the concept of seams. Generally, seams are a way to change what code does with external control. It might not be the way you’d design it from the start, but it’s a good way to migrate untestable code to being testable. I reread it recently and the techniques are still useful and easy to adapt to modern languages.
If I were to update my advice from 2004, I’d say to adopt diff-cover. It’s a script that filters down your code coverage reports to show the test coverage of the changes in your branch. This lets you try to hit high coverage numbers on each PR without necessarily having high coverage overall.
Other than that, if you have an untested codebase, I’d still say not to make it a project to add tests widely. It’s something I do it in my codebase even though its coverage is high. I want to keep it that way.
If you don’t have any tests, I’d get the infrastructure into place to run tests locally and in CI and a few to get a few tests going. Then, use tests to get your work done faster. I cover this in my book, Swimming in Tech Debt, which isn’t coming out until March, but I have a story of how I did this at Trello that was published in The Pragmatic Engineer.