>I have read that the changes to the code base should not change the unit tests.

Close, but no. Lets fix that:

Changes to the *implementation choices*, ones that change neither the behavior nor the API it's being tested through, should not change the unit tests.

There are many implementation choices that must be made in any codebase that can be changed without forcing changes to well designed unit tests. There's a whole book that talks about such choices. [Refactoring by Martin Fowler](https://martinfowler.com/books/refactoring.html).

A well designed unit test requires the code to exhibit a behavior and provide an API through which to elicit that behavior. Change either one and you break the unit test. But preserve that and the codebase is free to change as you like. Doing that is what Fowler called refactoring.

Your example code spectacularly fails to be unit testable code precisely because you provided no such API. This code is only testable with integration tests or obnoxious mocking.

A unit test should enforce expected behavior. The "behavior" code here is simply a hard coded string. One that is defined in multiple places. Sending that string to a console, a GUI or a print out is not a behavior. That's just a destination.

Writing unit testable code requires designing the code to be unit tested. A classic pattern for that is imperative shell, functional [core](https://kennethlange.com/functional-core-imperative-shell/). This pattern lets you push all the non-functional code away from your behavior code which will live in easily testable functional code that will provide that needed API.

This all boils down to writing code that does whatever logic to figure out what the greeting will be and unit testing that logic without knowing or caring where the greeting will go. Because that's IO. IO should not be part of the unit of a unit test. Push that out to the imperative shell. If you must test that (many don't) don't test it with a unit test.