Chapter 12. A Living Document

Tests are more than just making sure your code works, they also provide documentation.

John Reese et al., Unit testing best practices with .NET Core and .NET Standard

In Chapter 11, we undertook a relatively significant design change by introducing the Bank entity. Both the new test we wrote and the existing tests helped us in accomplishing this goal.

One feature of new Bank entity is its ability to accept and store an exchange rate between any pair of currencies. The way we designed (and tested) it — the exchange rates are stored in a hashmap and in the keys are formed from the two currencies — gives us reason to believe that we already have the next feature on our list. That feature is to allow exchange rates to be modified.

One way to gain confidence that this feature works is (no prizes for guessing) to write a test to prove it. Why should we write a test when the feature is likely already there? In other words, what could a new test possibly drive, if the _development has already been done?

Two answers can be provided to this question:

  1. To repeat: a new test would increase our confidence in this feature, even if no new production code is necessary.

  2. The new test would serve as executable documentation of this feature.

Tests are an effective way to document our code. Because we can (and should) use meaningful names for our tests, and because they lay out in detail what a feature does (as opposed to how it works); tests are an excellent way for newcomers to learn about our code. They can even help us reorient ourselves with our own code when we forget subtle yet significant details about its behavior.

Enlightened by this justification to write tests, let’s turn our focus on this possibly-implemented-but-not-tested feature on our list:

5 USD x 2 = 10 USD

10 EUR x 2 = 20 EUR

4002 KRW / 4 = 1000.5 KRW

5 USD + 10 USD = 15 USD

Separate test code from production code

Remove redundant tests

5 USD + 10 EUR = 17 USD

1 USD + 1100 KRW = 2200 KRW#

Determine exchange rate based on the currencies involved (from → to)

Improve error handling when exchange rates are unspecified

Improve the implementation of exchange rates

Allow exchange rates to be modified

Changing exchange rates

We’ll start by modifying our existing test for conversion. We already know that conversion with a rate between two currencies (e.g. EUR→USD) works. We’ll add to the test by adding a different rate for the same pair of currencies. We’ll validate that subsequent conversions utilize this new exchange rate.

If our test works out of the box, we’d have blitzed through the GREEN phase. We’ll address any necessary refactoring in the last phase.

Go

Let’s add a few more lines to the end of TestConversion. We’ll modify the exchange rate to 1.3 and validate that it takes effect. We’ll also rename the test to reflect its intent. Here is the entire test:

func TestConversion(t *testing.T) {
    tenEuros := s.NewMoney(10, "EUR")
    actualConvertedMoney, err := bank.Convert(tenEuros, "USD")
    assertNil(t, err)
    assertEqual(t, s.NewMoney(12, "USD"), *actualConvertedMoney) 1

    bank.AddExchangeRate("EUR", "USD", 1.3) 2
    actualConvertedMoney, err = bank.Convert(tenEuros, "USD") 3
    assertNil(t, err)
    assertEqual(t, s.NewMoney(13, "USD"), *actualConvertedMoney) 4
}
1

This was the last line in the test before

2

Updated exchange rate between the same two currencies

3

Reusing the same variables with = instead of := operator

4

Verifying that the conversion takes the updated rate into account

Voila! The test passes at first attempt.

By way of refactoring, we change the name of the test to better reflect its new intent: TestConversionWithDifferentRatesBetweenTwoCurrencies.

Out of curiosity: does the exchange rate between EUR and USD stay at 1.3 for tests that run after this test? That’s easy to verify. Let’s write a new test beneath TestConversionWithDifferentRatesBetweenTwoCurrencies in the money_test.go file. (Tests are run in the order they’re specified in the source file.)

func TestWhatIsTheConversionRateFromEURToUSD(t *testing.T) { 1
    tenEuros := s.NewMoney(10, "EUR")
    actualConvertedMoney, err := bank.Convert(tenEuros, "USD")
    assertNil(t, err)
    assertEqual(t, s.NewMoney(12, "USD"), *actualConvertedMoney) 2
}
1

Test name reflects its exploratory nature

2

Body of test is borrowed from the first half of the TestConversionWithDifferentRatesBetweenTwoCurrencies test

And the test fails with the unlucky number thirteen!

=== RUN   TestWhatIsTheConversionRateFromEURToUSD
    ... Expected  [{amount:12 currency:USD}] Got: [{amount:13 currency:USD}]

We needn’t be superstitious: it turns out that the init() method runs once during the test run, not before each test method. Any shared state modified by one test is visible to tests that run later. That is how we get thirteen dollars.

Important

Each init() method in a Go test file runs once, in the order in which it’s specified

Knowing this, we should update the TestConversionWithDifferentRatesBetweenTwoCurrencies test to use a new exchange rate that’s not set in the init method. Test independence is an important trait; it would be a bad idea to let one test subtly change shared data that affects other tests.

func TestConversionWithDifferentRatesBetweenTwoCurrencies(t *testing.T) {
    bank.AddExchangeRate("EUR", "KRW", 1300) 1
    tenEuros := s.NewMoney(10, "EUR")
    actualConvertedMoney, err := bank.Convert(tenEuros, "KRW") 2
    assertNil(t, err)
    assertEqual(t, s.NewMoney(13000, "KRW"), *actualConvertedMoney) 3

    bank.AddExchangeRate("EUR", "KRW", 1344) 4
    actualConvertedMoney, err = bank.Convert(tenEuros, "KRW") 5
    assertNil(t, err)
    assertEqual(t, s.NewMoney(13440, "KRW"), *actualConvertedMoney) 6
}
1

New exchange rate from EUR to KRW

2

First conversion from EUR to KRW

3

Verification that exchange rate is used for conversion

4

Updated exchange rate from EUR to KRW

5

Second conversion from EUR to KRW

6

Verification that updated exchange rate is used for conversion

All tests pass. We can now delete the exploratory TestWhatIsTheConversionRateFromEURToUSD — it has served its purpose.

There is still a subtle side-effect of this updated TestConversionWithDifferentRatesBetweenTwoCurrencies: there was no rate defined from EUR to KRW before the test; and there is one after the test runs. However, since we do not have any way to remove an exchange rate, this is the best we could do. If we test-drive a remove exchange rate feature, we could (and should) use it at the end of this test to clean up the shared bank.

JavaScript

We’ll add a few more lines ot the end of testConversion. We’ll modify the exchange rate between EUR and USD to 1.3 and verify that this change takes effect. Here’s the updated test method:

  testConversion() {
    let tenEuros = new Money(10, "EUR");
    assert.deepStrictEqual(this.bank.convert(tenEuros, "USD"),
      new Money(12, "USD")); 1

    this.bank.addExchangeRate("EUR", "USD", 1.3); 2
    assert.deepStrictEqual(this.bank.convert(tenEuros, "USD"),
      new Money(13, "USD")); 3
  }
1

This is the test we had before

2

Updated exchange rate between the same two currencies

3

Verifying that the conversion takes the updated rate into account

And lo! The test passes as written.

We refactor the name of the test to better indicate its purpose: testConversionWithDifferentRatesBetweenTwoCurrencies.

There is a subtle side-effect of our test, though. Because bank is a shared object amongst all the tests, the fact that we have changed the exchange rate is visible to all tests that run subsequently. We can verify this by writing a test after testConversionWithDifferentRatesBetweenTwoCurrencies. (Our test are discovered, and therefore run, in the order they’re declared in the source file.)

  testWhatIsTheConversionRateFromEURToUSD() { 1
    let tenEuros = new Money(10, "EUR");
    assert.deepStrictEqual(this.bank.convert(tenEuros, "USD"),
      new Money(12, "USD")); 2
  }
1

Test name reflects its exploratory nature

2

Body of test is borrowed from the first half of the testConversionWithDifferentRatesBetweenTwoCurrencies test

And the test duly fails with an assertion error:

Running: testWhatIsTheConversionRateFromEURToUSD()
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected

  Money {
+   amount: 13,
-   amount: 12,
    currency: 'USD'
  }

There are several ways to eliminate this undesirable side-effect of one test on another. We could do any of the following:

  1. Restore the EUR→USD exchange rate to the value set in the constructor at the end of the testConversionWithDifferentRatesBetweenTwoCurrencies

  2. Test-Drive a new Bank feature: removeExchangeRate and then use it at the end of testConversionWithDifferentRatesBetweenTwoCurrencies

  3. Use a new Bank object local to testConversionWithDifferentRatesBetweenTwoCurrencies so there are no side-effects

  4. Test-drive a “setUp / tearDown” feature in our test harness that allows us to create a new Bank before each test

  5. Use a different exchange rate in testConversionWithDifferentRatesBetweenTwoCurrencies that is not used by any other test

We’ll go with the last option, even though it only minimizes the impact of the side-effect; it doesn’t eliminate it.

  testConversionWithDifferentRatesBetweenTwoCurrencies() {
    this.bank.addExchangeRate("EUR", "KRW", 1300); 1
    let tenEuros = new Money(10, "EUR");
    assert.deepStrictEqual(this.bank.convert(tenEuros, "KRW"),
      new Money(13000, "KRW")); 2

    this.bank.addExchangeRate("EUR", "KRW", 1344); 3
    assert.deepStrictEqual(this.bank.convert(tenEuros, "KRW"),
      new Money(13440, "KRW")); 4
  }
1

New exchange rate from EUR to KRW

2

First conversion from EUR to KRW with verification

3

Updated exchange rate from EUR to KRW

4

Second conversion from EUR to KRW with verification that updated exchange rate is used

The test passes. We can now delete the exploratory`testWhatIsTheConversionRateFromEURToUSD` — it has served its purpose. All the tests are now green.

Python

We start by adding a few lines to the end of testConversion. We’ll vary the exchange rate between EUR and USD to 1.3 and assert that this new rate is used for a second conversion between the two currencies. Here’s the test method in its entirety:

  def testConversion(self):
        tenEuros = Money(10, "EUR")
        self.assertEqual(self.bank.convert(tenEuros, "USD"), Money(12, "USD")) 1

        self.bank.addExchangeRate("EUR", "USD", 1.3) 2
        self.assertEqual(self.bank.convert(tenEuros, "USD"), Money(13, "USD")) 3
1

This is the test we had before

2

Updated exchange rate between the same two currencies

3

Verifying that the conversion takes the updated rate into account

And voila! The test passes at first attempt.

We rename the test to testConversionWithDifferentRatesBetweenTwoCurrencies. This captures the new intent of the test more fully.

Out of curiosity: is the updated EUR→USD exchange rate in testConversionWithDifferentRatesBetweenTwoCurrencies visible to other tests? To verify this, we can write a test whose name causes it to run after all other tests. (Pytest runs tests in alphabetical order by name.)

Important

By default, tests in Python are run in the order of their alphabetically sorted test method names.

    def testWhatIsTheConversionRateFromEURToUSD(self): 1
        tenEuros = Money(10, "EUR")
        self.assertEqual(self.bank.convert(tenEuros, "USD"), Money(12, "USD")) 2
1

Test name reflects its exploratory nature

2

Body of this test is borrowed from the first half of testConversionWithDifferentRatesBetweenTwoCurrencies test

And this test also passes. Excellent! Python’s test framework ensures that there are no side-effects from one test to another, because the setUp method is run before each test.

Important

Using Python’s unittest package, subclassing the TestCase class, and overriding the setUp method promotes test isolation. The setUp method runs before each test, ensuring common objects are created afresh.

With our curiosity about test-independence quelled, we delete testWhatIsTheConversionRateFromEURToUSD — it has served its short-lived purpose.

With this fast lap around the RGR cycle, we’re done with this feature.

Committing our changes

We added a test to showcase an existing feature. Let’s highlight this in our Git commit message:

git add .
git commit -m "test: added test for modifying an existing exchange rate"

Where We Are

In this chapter, we added tests to document an existing feature and learned about test independence.

Important

Tests — especially unit tests — should be independent of each other. One test should not rely on the success, failure, or even side-effects caused by another test.

We’re done with all the features on our list.

There remains one significant aspect that our code would benefit from. Not a feature that would be present in production code, such as we’ve added in several preceding chapters. Not even a test that would give us more confidence, as we did in this chapter. But something that would add value by continuously validating our code.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset