Start The World's Best Introduction to TDD... free!

Tutorials Comments

Before You Begin

What is the Saff Squeeze? Read this tutorial.

The Example

I use jq to turn JSON into Plain Text Accounting journal entries. In particular, I administer a bowling league and use hledger to track the league finances. Each week, we collect cash from bowlers, pay some of that money to bowling center, and use the rest to fund prizes.

When we collect cash, we record what we collect, because I value accuracy. Here is a JSON document that represents the paper document we fill in each week.

{
  "date": "2023-11-22",
  "prepaid_bowling_fees": [],
  "tournament": "2023-2024:Singles Sprint",
  "strike_pot_ticket_sales_in_cents": 6500,
  "money_slips": {
    "bowlers": {
      "regulars": { "fulls": 19, "spares": 0 },
      "youths": { "fulls": 5, "spares": 1 }
    },
    "extra_prize_funds": 2
  }
}

I have this particular example handy because processing this document creates a small problem.

2023-11-22  Strike Pot ticket sales
    Revenues:Strike Pot Ticket Sales    -65 CAD
    Assets:Cash:Strike Pot Fund    32.5 CAD
    Assets:Cash:General Prize Fund:Reserved For Tournament:2023-2024:Singles Sprint    32.5 CAD

2023-11-22  Bowling Fees collected
    Revenues:Bowling Fees    -289.75 CAD    ; 19 regulars, 5 youths, 0 regular spares, 1 youth spares
    Liabilities:Lineage Payable    194.7 CAD    ; 19 regular bowlers
    Liabilities:Lineage Payable    49.45 CAD    ; 6 youth bowlers
    Assets:Cash:General Prize Fund:Reserved For Tournament:2023-2024:Singles Sprint

2023-11-22  Lineage paid
    Expenses:Lineage    244.14999999999998 CAD
    Liabilities:Lineage Payable

Well, maybe not a problem, but a risk. I would feel much better if that “Lineage” expense amount were 244.15 CAD.

So… why does this happen? Well, we can guess and look and guess and try and guess and poke around… or we can use the Saff Squeeze.

But wait! how does one write automated tests for jq code?

I’ve never tried. Meh. I’m not going to bother. I can use the Saff Squeeze even without automated tests.

It’s Like the Saff Squeeze…

Yes, the Saff Squeeze works really well when we have automated tests, then successively inline a part of the test, pruning away the irrelevant parts and adding assertions whenever the code tests an if condition. We can still use it when we don’t have automated tests. That’s what I did, rather than invest time in figuring out how to write automated tests for jq. (I’m sure someone has done that, but I’m not interested right now. I have an article to write!)

I chose to write a jq expression in a script, then successively inline parts of the expression until the defect became obvious. It’s not exactly the Saff Squeeze, but it’s like the Saff Squeeze. And it captures the intent of the Saff Squeeze.

Step 1: Write a Failing Test That Demonstrates the Defect

The Expenses:Lineage amount shows the problem, so I focus on the code that computes that value and discard the rest.

jq -L "scripts/financials" -n 'import "process_money_slips" as ProcessMoneySlips; { "bowlers": { "full": { "regular": 19, "youth": 5 }, "spare": { "regular": 0, "youth": 1 } },  "extra_prize_funds": 0 } | ProcessMoneySlips::lineage_paid_of'

Notice that this line of code is similar to a unit test:

  • It provides the example input directly, using jq -n, rather than insisting on providing the input data from a file, the way production code does.
  • The example data includes only the parts of the input that the action of the test needs.1
  • It invokes lineage_paid_of directly, rather than running the code from end to end.

When I run the test, I see an unrounded result, which I interpret as “test failed”.

244.149999999998

I would probably prefer to move this assertion to the script, but I don’t have the energy and this gives me enough information to move forward.

Step 2: Inline the Action

The Saff Squeeze has two key parts: inline the action where the defect probably lies and prune away irrelevant details. Doing this over and over eventually makes the defect easy to see merely by reading the test.

I inlined the action and added that to the script.

def lineage_paid_of:
  regulars_lineage_payable_of + youths_lineage_payable_of;
jq -L "scripts/financials" -n 'import "process_money_slips" as ProcessMoneySlips; { "bowlers": { "full": { "regular": 19, "youth": 5 }, "spare": { "regular": 0, "youth": 1 } },  "extra_prize_funds": 0 } | ProcessMoneySlips::lineage_paid_of'
jq -L "scripts/financials" -n 'import "process_money_slips" as ProcessMoneySlips; { "bowlers": { "full": { "regular": 19, "youth": 5 }, "spare": { "regular": 0, "youth": 1 } },  "extra_prize_funds": 0 } | ProcessMoneySlips::regulars_lineage_payable_of + ProcessMoneySlips::youths_lineage_payable_of'

I expect this to show the same unrounded output twice and it does.

244.149999999998
244.149999999998

But now it’s already becoming difficult to understand what’s going on, so I add some labels to the calculations.

echo -n "lineage_paid_of: "; jq -L "scripts/financials" -n 'import "process_money_slips" as ProcessMoneySlips; { "bowlers": { "full": { "regular": 19, "youth": 5 }, "spare": { "regular": 0, "youth": 1 } },  "extra_prize_funds": 0 } | ProcessMoneySlips::lineage_paid_of'
echo -n "inlined lineage_paid_of: "; jq -L "scripts/financials" -n 'import "process_money_slips" as ProcessMoneySlips; { "bowlers": { "full": { "regular": 19, "youth": 5 }, "spare": { "regular": 0, "youth": 1 } },  "extra_prize_funds": 0 } | ProcessMoneySlips::regulars_lineage_payable_of + ProcessMoneySlips::youths_lineage_payable_of'

This makes the output easier to understand.

lineage_paid_of: 244.14999999999998
inlined lineage_paid_of: 244.14999999999998

But remember that I already added some text to explain what we’re looking at here and how to interpret it.

When an output value is rounded to the nearest penny, that test passes; when it isn't, that test fails.

lineage_paid_of: 244.14999999999998
inlined lineage_paid_of: 244.14999999999998

Step 3: Prune Irrelevant Details

There are no ignored branches to prune away, so there’s nothing to do at this step.

Step 4: Inline More of the Action

At this step, we could inline each part of the calculation and see what happens, but my intuition tells me that it might be helpful to split this into two tests: one for regulars_lineage_payable_of and one for youths_lineage_payable_of. I suppose you could think of this as pruning irrelevant details, and therefore part of the preceding step.

I add these two lines to the script:

echo -n "regulars_lineage_payable_of: "; jq -L "scripts/financials" -n 'import "process_money_slips" as ProcessMoneySlips; { "bowlers": { "full": { "regular": 19, "youth": 5 }, "spare": { "regular": 0, "youth": 1 } },  "extra_prize_funds": 0 } | ProcessMoneySlips::regulars_lineage_payable_of'
echo -n "youths_lineage_payable_of: "; jq -L "scripts/financials" -n 'import "process_money_slips" as ProcessMoneySlips; { "bowlers": { "full": { "regular": 19, "youth": 5 }, "spare": { "regular": 0, "youth": 1 } },  "extra_prize_funds": 0 } | ProcessMoneySlips::youths_lineage_payable_of'

I could have pruned away the irrelevant parts of the input, but I chose not to out of the purest laziness and excitement. I’ll do that as part of cleaning up before moving on. (Future jbrains actually did this. Keep reading.)

When I run the script, I expect to see more unrounded amounts, but then I’m surprised.

When an output value is rounded to the nearest penny, that test passes; when it isn't, that test fails.

lineage_paid_of: 244.14999999999998
inlined lineage_paid_of: 244.14999999999998
regulars_lineage_payable_of: 194.7
youths_lineage_payable_of: 49.45

Excellent! This immediately suggests that the problem lies in adding floating-point values, not in computing each of the two values to add together. This allows me to prune irrelevant details!

Step 5: Prune Irrelevant Details

What happens if I just add those constants? Does that already demonstrate the problem?

echo -n "add floating-point constants: "; jq -L "scripts/financials" -n '194.7 + 49.45'

Why yes, it does!

When an output value is rounded to the nearest penny, that test passes; when it isn't, that test fails.

lineage_paid_of: 244.14999999999998
inlined lineage_paid_of: 244.14999999999998
regulars_lineage_payable_of: 194.7
youths_lineage_payable_of: 49.45
add floating-point constants: 244.14999999999998

Step 6: Eureka!

I think I know the defect: it’s a limitation of floating-point arithmetic. This means that my code should add pennies as integers as long as possible, then convert to dollars only at the last possible moment. I confirm this guess with one more test.

echo -n "add pennies, then convert to dollars: "; jq -L "scripts/financials" -n '(19470 + 4945) / 100.0'

Yup. Now I get 244.15, just as I’d prefer.

When an output value is rounded to the nearest penny, that test passes; when it isn't, that test fails.

lineage_paid_of: 244.14999999999998
inlined lineage_paid_of: 244.14999999999998
regulars_lineage_payable_of: 194.7
youths_lineage_payable_of: 49.45
add floating-point constants: 244.14999999999998
add pennies, then convert to dollars: 244.15

Step 7: Get Things Out of My Head

I could declare victory, fix the code, confirm that it works, then get on with my life, but if I did that, I’d risk losing valuable information already decaying in my memory. Accordingly, I write things down while it remains fresh in my mind. Here is the updated output:

When an output value is rounded to the nearest penny, that test passes; when it isn't, that test fails.

lineage_paid_of: 244.14999999999998
inlined lineage_paid_of: 244.14999999999998
regulars_lineage_payable_of: 194.7
youths_lineage_payable_of: 49.45
add floating-point constants: 244.14999999999998
add pennies, then convert to dollars: 244.15

It seems that we should add pennies, then convert to dollars at the last possible moment.

Now, at least, when I run this script in the future, if the output doesn’t match the conclusion at the end, then I know I need to investigate further. This matches the spirit of the xUnit test libraries, which pride themselves in saying as little as possible when the tests all pass and yelling loudly when there is a failure to investigate.

The Final Test Script

Here is the current content of test_rounding:

#!/usr/bin/env bash
echo "When an output value is rounded to the nearest penny, that test passes; when it isn't, that test fails."
echo ""

echo -n "lineage_paid_of: "; jq -L "scripts/financials" -n 'import "process_money_slips" as ProcessMoneySlips; { "bowlers": { "full": { "regular": 19, "youth": 5 }, "spare": { "regular": 0, "youth": 1 } },  "extra_prize_funds": 0 } | ProcessMoneySlips::lineage_paid_of'
echo -n "inlined lineage_paid_of: "; jq -L "scripts/financials" -n 'import "process_money_slips" as ProcessMoneySlips; { "bowlers": { "full": { "regular": 19, "youth": 5 }, "spare": { "regular": 0, "youth": 1 } },  "extra_prize_funds": 0 } | ProcessMoneySlips::regulars_lineage_payable_of + ProcessMoneySlips::youths_lineage_payable_of'
echo -n "regulars_lineage_payable_of: "; jq -L "scripts/financials" -n 'import "process_money_slips" as ProcessMoneySlips; { "bowlers": { "full": { "regular": 19, "youth": 5 }, "spare": { "regular": 0, "youth": 1 } },  "extra_prize_funds": 0 } | ProcessMoneySlips::regulars_lineage_payable_of'
echo -n "youths_lineage_payable_of: "; jq -L "scripts/financials" -n 'import "process_money_slips" as ProcessMoneySlips; { "bowlers": { "full": { "regular": 19, "youth": 5 }, "spare": { "regular": 0, "youth": 1 } },  "extra_prize_funds": 0 } | ProcessMoneySlips::youths_lineage_payable_of'
echo -n "add floating-point constants: "; jq -L "scripts/financials" -n '194.7 + 49.45'
echo -n "add pennies, then convert to dollars: "; jq -L "scripts/financials" -n '(19470 + 4945) / 100.0'

echo ""
echo "It seems that we should add pennies, then convert to dollars at the last possible moment."

I now have enough information to fix the defect and I have recorded enough information to understand this script two years from now, in case I need to run it.

And now, before I declare victory, let me remove the parts of the input that each test doesn’t need.

The Final Final Test Script

#!/usr/bin/env bash
echo "When an output value is rounded to the nearest penny, that test passes; when it isn't, that test fails."
echo ""

echo -n "lineage_paid_of: "; jq -L "scripts/financials" -n 'import "process_money_slips" as ProcessMoneySlips; { "bowlers": { "full": { "regular": 19, "youth": 5 }, "spare": { "regular": 0, "youth": 1 } } } | ProcessMoneySlips::lineage_paid_of'
echo -n "inlined lineage_paid_of: "; jq -L "scripts/financials" -n 'import "process_money_slips" as ProcessMoneySlips; { "bowlers": { "full": { "regular": 19, "youth": 5 }, "spare": { "regular": 0, "youth": 1 } } } | ProcessMoneySlips::regulars_lineage_payable_of + ProcessMoneySlips::youths_lineage_payable_of'
echo -n "regulars_lineage_payable_of: "; jq -L "scripts/financials" -n 'import "process_money_slips" as ProcessMoneySlips; { "bowlers": { "full": { "regular": 19 }, "spare": { "regular": 0 } } } | ProcessMoneySlips::regulars_lineage_payable_of'
echo -n "youths_lineage_payable_of: "; jq -L "scripts/financials" -n 'import "process_money_slips" as ProcessMoneySlips; { "bowlers": { "full": { "youth": 5 }, "spare": { "youth": 1 } } } | ProcessMoneySlips::youths_lineage_payable_of'
echo -n "add floating-point constants: "; jq -n '194.7 + 49.45'
echo -n "add pennies, then convert to dollars: "; jq -n '(19470 + 4945) / 100.0'

echo ""
echo "It seems that we should add pennies, then convert to dollars at the last possible moment."

Now I can fix the defect with confidence.

Summary

You can use the Saff Squeeze even if you don’t have automated tests. It’s enough to do this:

  1. Compute the answer and document how to interpret it as “correct” or “incorrect”. If you have to do this manually by clicking, then write a Test Script like it’s 1993.
  2. Prune irrelevant details from the “test”. Confirm that it continues to fail the same way it did before.
  3. Inline the action and add this as another “test”. Confirm that it also fails.
  4. Prune irrelevant details from the new “test”. Convert if conditions into assertions, which means adding a new, smaller “test” that expects that condition. Document the assertion so that it will be clear to you even when you read it years from now.
  5. Inline the action or write a smaller “test” for only one part of the action. You can choose. Remember to add these as new “tests”; don’t throw the old ones away.
  6. Repeat pruning and inlining until the defect becomes obvious.

What matters most to me are these two guidelines:

  • Add “tests” as you zoom in on the location of the problem. Don’t throw away the old “tests”. This leaves a record of what you learned. It also leaves a handful of unit “tests” that you can use as inspiration for when you sit down to write actual unit tests in the future. (Eventually. Right?)
  • Whenever you think something even vaguely interesting, write it down in or among your “tests”. Add explanatory text to the output if you can; write comments otherwise. It’s no good to anyone in your head; write it down.

Enjoy.


  1. Well… almost. Now that I’m writing the article, I notice that this pseudotest includes extra_prize_funds in the input, even though lineage_paid_of doesn’t need it. Oops. I’ll remove that when I clean up before moving on.↩︎

Comments