Tags

, , ,

My team is committed to Test-Driven Development. Therefore, I was struck with remorse recently when I found myself writing some bash code without having any automated unit tests. In this post, I’ll show how we made it right.

Context: this is a small utility written in bash, but it will be used for a fairly important task that needs to work. The task was to parse six-character record locators out of a text file and cancel the associated flight reservations in our test system after the tests had completed. Aside: I was also pair programming at the time, but I take all the blame for our bad choices.

We jumped in doing manual unit testing, and fairly quickly produced this script, cancelpnrs.bash:

#!/usr/bin/env bash

for recordLocator in $(egrep '\|[A-Z]{6}\s*$'|cut -d '|' -f 2)
do 
  recordLocator=$(echo -n $recordLocator|tr -d '\r')
  echo Canceling $recordLocator
  curl "http://testdataservice/cancelPnr?recordLocator=$recordLocator"
  echo
done

The testing cycles at the command line started with feeding a sample data file to egrep. We tweaked the regular expression until it was finding what it needed and filtering out the rest. Then we added the call to cut to output the record locator from each line, and then put it in a for loop. I like working with bash code because it’s so easy to build and test code incrementally like this.

After feeling remorse for shirking the ways of TDD, I remembered having some halting successes in the past with writing unit tests for bash code. We installed bats, the Bash Automated Testing System, then wrote a couple of characterization tests as penance:

#!/usr/bin/env bats

# Requires that you run from the same directory as cancelpnr.bash

load 'test/libs/bats-support/load'
load 'test/libs/bats-assert/load'

scriptToTest=./cancelpnrs.bash

@test "Empty input results in empty output" {
  run source "$scriptToTest" </dev/null

  assert_equal "$status" 0
  assert_output ""
}

@test "PNRs are canceled" {
  function curl() { 
    echo "Successfully canceled: (record locator here)"
  }
  export -f curl

  run source "$scriptToTest" <<EOF
	                        Thu Apr 02 14:23:45 CDT 2020
Checkin2Bags_Intl|LZYHNA
Checkin2Bags_TicketNum|SVUWND
EOF

  assert_equal "$status" 0
  assert_output --partial "Canceling LZYHNA"
  assert_output --partial "Canceling SVUWND"
}

We were pretty pleased with the result. Of course, the test is a good deal more code than the code under test, which is typical of our Java code as well. We installed the optional bats-support and bats-assert libraries so we could have some nice xUnit-style assertions. A few other things to note here–when we’re invoking the code under test using “source“, it runs all of the code in the script. This is something we’ll improve upon shortly. We needed to stub out the call to curl because we don’t want any unit test to hit the network. This was easy to do by creating a function in bash. The sample input in the second test gives anyone reading the test a sense for what the input data looks like.

Looking at the code we had, we saw some opportunity for refactoring to make the code easier to understand and maintain. First we needed to make the code more testable. We knew we wanted to extract some of the code into functions and test those functions directly. We started by moving all the cancelpnrs.bash code into one function, and added one line of code to call that function. The tests still passed without modification. Then we added some logic to detect whether the script is being invoked directly or sourced into another script, and it only calls the main function when invoked directly. So when sourced by the test, the code does nothing but defines functions, but it still works the same as before when invoked on the command line. We changed the existing tests to call a function rather than just expecting all of the code to run when we source the code under test. This transformation was typical of any kind of script code that you would want to unit test.

At this point, following a proper TDD process felt very similar to the development process in any other language. We added a test to call a function we wanted to extract, and fixed bugs in the test code until it failed because the function didn’t yet exist. Then we refactored the code under test to get back to “green” in all the tests. Here is the current unit test code with two additional tests:

#!/usr/bin/env bats

# Requires that you run from the same directory as cancelpnrs.bash

load 'test/libs/bats-support/load'
load 'test/libs/bats-assert/load'

scriptToTest=./cancelpnrs.bash
carriageReturn=$(echo -en '\r')

setup() {
  source "$scriptToTest"
}

@test "Empty input results in empty output" {
  run doCancel </dev/null

  assert_equal "$status" 0
  assert_output ""
}

@test "PNRs are canceled" {
  function curl() {
    echo "Successfully canceled: (record locator here)"
  }
  export -f curl

  run doCancel <<EOF
	                        Thu Apr 02 14:23:45 CDT 2020

Checkin2Bags_Intl_RT|LZYHNA
Checkin2Bags_TicketNum_Intl_RT|SVUWND
EOF

  assert_equal "$status" 0
  assert_output --partial "Canceling LZYHNA"
  assert_output --partial "Canceling SVUWND"
}

@test "filterCarriageReturn can filter" {
  doTest() {
    echo -n "line of text$carriageReturn" | filterCarriageReturn
  }

  run doTest

  assert_output "line of text"
}

@test "identifyRecordLocatorsFromStdin can find record locators" {
  doTest() {
    echo -n "testName|XXXXXX$carriageReturn" | identifyRecordLocatorsFromStdin
  }

  run doTest

  assert_output $(echo -en "XXXXXX\r\n")
}

You’ll see some code that deals with the line ending characters “\r” (carriage return) and “\n” (newline). Our development platform was Mac OS, but we also ran the tests on Windows because the cancelpnrs.bash script also needs to work in a bash shell on Windows. The script ran fine under git-bash on Windows, but it took some tweaking to get the tests to work on both platforms. There is surely a better solution to make the code more portable.

We installed bats from source and committed it to our source repository, and followed the instructions to install bats-support and bats-assert as git submodules. We’re not really familiar with submodules and not entirely happy with having to do a separate installation of the submodules on every system we clone our repository to (we have to run “git submodule init” and “git submodule update” after cloning, or else remember to add the option “–recurse-submodules” to the clone command).

Running the tests takes a fraction of a second. It looks like this:

$ ./bats test-cancelpnrs.bats 
 ✓ Empty input results in empty output
 ✓ PNRs are canceled
 ✓ filterCarriageReturn can filter
 ✓ identifyRecordLocatorsFromStdin can find record locators

4 tests, 0 failures

Here is the current refactored version of cancelpnrs.bash:

#!/usr/bin/env bash

cancelEndpoint='http://testdataservice/cancelPnr'

doCancel() {
  for recordLocator in $(identifyRecordLocatorsFromStdin)
  do
    recordLocator=$(echo -n $recordLocator | filterCarriageReturn)
    echo Canceling $recordLocator
    curl -s --data "recordLocator=$recordLocator" "$cancelEndpoint"
    echo
  done
}

identifyRecordLocatorsFromStdin() {
  egrep '\|[A-Z]{6}\s*$' | cut -d '|' -f 2
}

filterCarriageReturn() {
  tr -d '\r'
}

if [ "${BASH_SOURCE[0]}" -ef "$0" ]
then
  doCancel
fi

There are two lines of code not covered by unit tests. Because the one test that hits the loop body in the doCancel stubs out curl, the actual curl call is not tested. Also, the doCancel call near the bottom is never tested by the unit tests. We ran manual system tests with live data as a final validation, and don’t see a need at this point to automate those tests.

So there you go – no more excuses!