First I declare a "foo.src.js" file to hold a module's logic, and then a test file as "foo.js" and have the test file import the logic and then export whatever is to be public. Client code imports the test file and not the source file, so the tests sit in between the logic and the client code, as it were. No more balancing testability against encapsulation.
The second is indirection. I create a mockability decorator. Then have a function (I call it 'mock') which can be used to temporarily redefine any function declared mockable, and then switch back to the real thing when the current test is through. So I get:
const readFile = mockable(fs.promises.readFile)
...
mock(readFile)(async () => 'mock file contents')
Combining these approaches doesn't so much prevent rework on tests when code changes as it makes it much less trouble to implement tests for 98% of cases.