The advice to write tests before refactoring is correct and, on the codebases where it is most needed, circular. The code is untestable precisely because of the things you want to change - a single enormous function, dependencies constructed inline, database access woven through business logic. You cannot get it under test without changing it, and you cannot change it safely without tests.
The way out is characterisation testing, which inverts what a test is for. You are not asserting what the code should do; you are recording what it currently does, including the behaviour that is arguably wrong. Call the function with realistic inputs, capture whatever comes out, and assert that it stays the same. The test is now a tripwire against unintended change, which is exactly what a refactor needs. Bugs discovered along the way get written down and fixed separately, after the structural work, so that one change is never both a refactor and a behavioural fix.
To get the code callable at all, use the smallest possible structural changes first - the ones a compiler or IDE can perform mechanically. Extract a method. Introduce a parameter. Move a constructed dependency to a parameter so a test can pass a substitute. These are individually boring, are verifiable by tooling rather than judgement, and are what make the interesting refactor possible.
Then work in small, separately committed steps, running the characterisation tests between each. A refactor delivered as one enormous commit cannot be reviewed, cannot be bisected when something breaks, and cannot be partially reverted. Twenty small commits each of which keeps the system working is slower to type and dramatically faster when something goes wrong.
Choose where to work by change frequency, not by how much the code offends you. Pull the commit history and find the files that change most often - that is where the pain is actually being paid, and improving them returns value immediately. The genuinely awful module nobody has touched in three years is costing you nothing, and rewriting it is a hobby rather than work.
Two guardrails for anything risky. Keep the old path available behind a flag so a problem is a toggle rather than a rollback. And where the output is verifiable, run both implementations in parallel for a period, comparing results and logging differences without acting on the new one. It is more work and it is the only way to be genuinely confident about a change to something like a pricing calculation - where being subtly wrong is much worse than being obviously broken.