Testivus: Unit Testing Philosophy

Iman Tung
9 min readFeb 6, 2022
Image Source

Is unit testing really guarantees good code? Am I a good coder by writing one? Is my test good enough? Is it redundant with QA tasks? What’s the truth behind it?

We are questioning Unit Testing like questioning life, maybe we should think more philosophical when discussing it. The author would like to give a commentary for the classic The Way of Testivus (Less Unit Testing Dogma, More Unit Testing Karma).

P.S. A long article; I put the whole Testivus text along with the commentaries.

1. If you write code, write tests

The pupil asked the master programmer:
“When can I stop writing tests?”

The master answered:
“When you stop writing code.”

The pupil asked:
“When do I stop writing code?”

The master answered:
“When you become a manager.”

The pupil trembled and asked:
“When do I become a manager?”

The master answered:
“When you stop writing tests.”

The pupil rushed to write some tests.
He left skid marks

If the code deserves to be written,
it deserves to have tests.

The story told that to always write tests for the code. But what is Unit Testing?

Word “unit” by definition (oxford dictionary) means a single thing is complete by itself but can also form part of something larger.

“Unit” in a computer program are functions, put simply “Unit Test” is testing for a function.

func Fly() string {          // This is a unit
// ...
}
func TestFly(t testing.T){ // This is a unit testing
// ...
}

Unit Test is the cheapest (the scope only per function) and fastest (executed in a few seconds) in the Test Pyramid. If considering the large number of units that need to be tested, having proper unit testing is not an easy task at all.

Image Source

2. Don’t get stuck on unit testing dogma

Dogma says:

“Do this.
Do only this.
Do it only this way.
And do it because I tell you.”

Dogma is inflexible.
Testing needs flexibility.

Dogma kills creativity.
Testing needs creativity

Dogma is common for the programming guy. We have many rules and best practices in order to deliver good software. Be flexible, don’t equate code and test. Following the code convention may not be so appropriate for tests.

No problem to disobey naming conventions for test names since it is called by a test runner (not by a human).

// Following naming convention 
// but not so easy to read

func TestLegacyServiceUpdateActivity(t *testing.T) {
// ...
}
// Put underscore between class name
// and function give more readablity

func TestLegacyService_UpdateActivity(t *testing.T) {
// ...
}

In another example, (in programming) we’re avoiding magic numbers (always put the value to a variable). In testing, it is good-to-go as long as able to give a better sense (readability over maintainability).

// No need for variable
// fish := "some-fish"
// Magic values are fine
m.EXPECT().ThrowFishNet().Return("some-fish")
m.EXPECT().PutToBasket("some-fish").Return(true)

3. Embrace unit testing karma

Karma says:

“Do good things and good things will happen to you.
Do them the way you know.
Do them the way you like.”

Karma is flexible.
Testing needs flexibility.

Karma thrives on creativity.
Testing needs creativity

Not only writing good tests are good karma, but writing tests itself can follow karma works (any input leads to its result as consequence). On Data-Driven Testing, we define a set of input and its expected result.

func TestIsOddOrEven(t *testing.T) {
// The "karma" table
testcases := []struct {
TestName string
Number int
Expected string
}{
{Number: 1, Expected: "even"},
{Number: 88, Expected: "odd"},
{Number: 999992, Expected: "odd"},

}
for _, tt := range testcases {
t.Run(tt.TestName, func(t *testing.T) {
result := IsOddOrEven(tt.Number)
if result != tt.Expected {
t.Fatalf("Test Case Failed: %+v", tt)
}
})
}
}

4. Think of code and test as one

When writing the code, think of the test.
When writing the test, think of the code.

When you think of code and test as one,
testing is easy and code is beautiful.

To ensure “code and test as one”, Test-Driven Development (TDD) advocates writing tests as earlier as before implementing the code.

TDD doesn’t mean to validate the software function (we write it first, not the after) but rather a design methodology. The engineer has to finish the idea in their mind then translate it into the Unit Test, therefore coding will rely on tests and no more thinking (brainless coding).

And because “code and test as one”, put them in a single git commit. In fact, it is not possible to commit tests before code as the process goes back and forth between both. Any additional test will be responded to code refactoring, any code change will revise the test.

Image Source

5. The test is more important than the unit

The pupil asked the great master programmer Flying Feathers:
“What makes a test a unit test?”

This great master programmer answered:
“If it talks to the database, it is not a unit test.
If it communicates across the network, it is not a unit test.
If it touches the file system, it is not a unit test.
If it can’t run at the same time as any other unit tests, it is not a unit test.
If you have to do special things to your environment to run it, it is not a unit test.”

Other master programmers jumped in and started arguing.

“Sorry I asked,” said the pupil. Later that night, he received
a note from the grand master programmer. The note said:

“The answer from the great master Flying Feathers is an excellent guide.
Follow it, and most of the time you will do well.
But don’t get stuck on any dogma.
Write the test that needs to be written.”

The pupil slept well.
The other masters continued to argue long into the night.

What great master programmer said probably F.I.R.S.T Principle from another classic book of Clean Code.

  1. Fast — Test case should be fast
  2. Isolate — Not depend on other test cases
  3. Repeatable — Same results each time
  4. Self-Validating — No manual interpretation
  5. Throughly — Should cover every use case scenario and NOT just aim for 100% coverage

6. The best time to test is when the code is fresh

Your code is like clay.
When it’s fresh, it’s soft and malleable.
As it ages, it becomes hard and brittle.

If you write tests when the code is fresh
and easy to change, testing will be easy,
and both the code and the tests will be strong.

Preparing a complete unit test for a function can be a difficult task.

We can break it down into smaller parts and exercise smaller TDD circles as for a baby (baby-steps TDD). Every circle may consist few lines of tests/code and be repeated until the whole function.

Image Source

7. Tests not run waste away

Run your tests often.
Don’t let them get stale.
Rejoice when they pass.
Rejoice when they fail.

Not just run tests often but automate it. Continuous testing is one of the core DevOps practices. It is an important step to prevent bad releases got deployed.

Image Source

8. An imperfect test today is better than a perfect test someday

The perfect is the enemy of the good.
Don’t wait for best to do better.
Don’t wait for better to do good.
Write the test you can today.

Rather than enforcing write test first, Martin Fowler more emphasizes having self-testing code (built-in test within the code). It is fine to write a test later for this concept. TDD actually one way to achieve self-testing code.

Another practice of self-testing code is to create unit tests to reproduce production bugs to validate the solution.

func TestUpdateActivityIssue_06022021(t *testing.T) {
// Reproducing bug to make sure it solved
// and never happened again
// ...
}

9. An ugly test is better than no test

When the code is ugly, the tests may be ugly.

You don’t like to write ugly tests,
but ugly code needs testing the most.

Don’t let ugly code stop you from writing tests,
but let ugly code stop you from writing more of it.

One of the ugly test methods is using monkey patching (override function execution during the runtime). It’s not supported in all programming languages but it can very handy to tackle especially standard-function cases.

func TestSomething(t *testing.T) {
// monkey patching
patch := monkey.Patch(time.Now, func() time.Time {
t1, _ := time.Parse(time.RFC3339, "2012-11-01T22:08:41+00:00")
return t1
})
defer patch.Unpatch()
// ...
}

10. Sometimes, the test justifies the means

The pupil asked two master programmers:
“I cannot test this code without mocking and violating encapsulation.
What should I do?”

One master programmer answered:
“Mocking is bad, and you should never violate encapsulation.
Rewrite the code so you can test it properly.”

The other master programmer answered:
“Mocking is good and testing trumps encapsulation.”

The pupil, confused, went out for a beer. At the local watering hole he saw
the great grand master programmer drinking beer and eating buffalo wings.

“Great grand master,” said the pupil, “I thought you did not drink.
And aren’t you a vegetarian?”

The great grand master smiled and replied:
“Sometimes your thirst is best quenched by beer
and your hunger by buffalo wings.”

The pupil was no longer confused.

Sometimes we require to violate encapsulation (directly accessing the attribute, using global variables, etc) and create the mocking class to be able to write tests for dependencies. The story told us to be aware of our needs (thirst/hunger) rather than the dogma.

Furthermore, we shouldn‘t limit to “unit” behavior but rather what systems do. We see unit dependencies as part of the behavior.

func operation1() {         // Unit test passed
// ...
}
func operation2() { // Unit test passed
// ...
}
func complexOperation() { // Should we rewrite all test case
// for both operation1 & operation2?
operation1()
operation2()
// ...
}

Behavior-driven development (BDD) extends TDD by combining it with Acceptance Testing, the development guided by system specification rather than just test. The BDD-style unit usually follows the Given-When-Then format.

GIVEN a context
WHEN some condition
THEN expect some output

11. Only fools use no tools

The farmer who does not use a plow
is not a good farmer.

The accountant who does not use an abacus
is not a good accountant.

Some tasks are best done with bare hands.
Other tasks are best done with tools.

It is not noble to do by hand
what can be done better with a tool.

It is not wise to use your head
when your head is not needed.

There is a lot of testing frameworks or assertion libraries that can help with writing test.

Image Source

But ultimately Unit Test itself is a tool to make development easier. Like another tool, we may need quite a time of practice and experience in order to master our tool.

12. Good tests fail

The pupil went to the master programmer and said:

“All my tests pass all the time. Don’t I deserve a raise?”

The master slapped the pupil and replied:

“If all your tests pass, all the time, you need to write better tests.”

With a red cheek, the pupil went to HR to complain.
But that’s another story.

If your tests never failed, you probably do it wrong.

Image Source

--

--

Iman Tung

Technology to write, life to grateful. Overthinking is good, only if it has the output. Fundamental is the main concern.