I have read that the changes to the code base should not change the
unit tests.
This 100000% makes sense and is critical to good unit testing.
The key thing, which I think you realise, is that the unit test should test the desired behaviour, which you don't expected to change regardless of implementation.
The goal of this rule is try and stop people writing unit tests which test the detail of implementations rather than the overall results of operations.
Your example is a good one in that both implementations hide what they do, one writes to console and one to messagebox, but they do it as a non-exposed, internal to the class thing. This type of thing would normally be considered part of the implementation, but in your case writing out the message is also the desired behaviour.
How do you follow the unit testing rule about not testing internal behaviour/implementation details whilst also testing the desired behaviour of writing messages? You have to change the class to make unit testing possible.
so for example you could inject the output writer
public interface IGreetingService
{
void ShowGreeting();
}
public class GreetingService : IGreetingService
{
IStreamWriter ouput
public GreetingService(IStreamWriter ouput) { this.output = output }
public void ShowGreeting()
{
ouput.WriteLine("Hello!");
}
}
Now I can inject the message box or the console as required and the behaviour of the class is to write "Hello!" to the stream that is passed in. Which makes your unit test better.
TestGreeting()
{
var testOutput = new Mock<IStreamWriter>();
var target = new GreetingService(testOutput);
target.ShowGreeting();
var actual = testOutput.ReadLine();
Assert(actual = "Hello!");
}
Now your unit test test the desired behaviour and won't change with the implementation. Because of the way you have defined "desired behaviour" and "implementation" for your class.
By following the rule you have been forced to change the design of your codebase. This is the intent of the rule, not to say that you will never have to rewrite your tests, but to try make you design your code in a such a way that your requirements are testable.
Also! there is an common anti pattern where people deliberately tests the internals of their classes and miss the real requirements. The rule tries to steer you away from doing this.
eg.
string Translate(string in)
{
var out = "";
var words = this.splitIntoWords(in);
foreach(var w in words)
{
t = this.translate(w);
out += w + " ";
}
return out
}
TranslateTest
{
var t = new Translator();
var out = t.Translate("hello world");
Assert(t.splitIntoWords("hello world").WasCalled());
Assert(t.translate("hello").WasCalled());
Assert(t.translate("world").WasCalled());
}
Here our class is fully tested, however the actual requirements are not. It's not required that you split the string, or translate each word individually, that's just how you have chosen to implement the translation. The test will break if you change the implementation, even if the result is the same. Which we call "brittle", and in fact this test doesn't test the real requirement of was a correct translation returned.