Presented at CppCon 2019 on 16th September 2019.
You've inherited some legacy code: it's valuable, but it doesn't have tests, and it wasn't designed to be testable, so you need to start refactoring. But you can't refactor safely until the code has tests, and you can't add tests without refactoring! How can you ever break out of this loop?
Whether Legacy code for you means "old code", "code without tests", or "code you wish to redesign for new features or unit-tests", this talk will enable you to become productive and work safely, quickly.
The simplicity, convenience, ease-of-use, power and flexibility of Llewellyn Falco's "Approval Tests" approach has long been proven in a dozen programming languages. And now all this is now available to C++ developers too!
Clare will present a small but surprisingly effective C++11 library for applying "Approval Tests" to cross-platform C++ code - for both legacy and green-field systems, and with a range of testing frameworks.
She will describe its use in some real-world situations, including how to quickly lock down the behaviour of legacy code. She will show how to quickly achieve good test coverage, even for very large sets of inputs. Finally, she will describe some general techniques she learned along the way.
Attendees will discover some quick, practical techniques to use for common challenges, such as testing outputs containing dates and times, that can be applied very easily using Approval Tests.
22. 22
What does Legacy Code really mean?
• Dictionary
–“money or property that you receive from someone after they die ”
• Michael Feathers
–“code without unit tests”
• J.B. Rainsberger
–“profitable code that we feel afraid to change.”
• Kate Gregory
–“any code that you want to change, but are afraid to”
23. 23
Typical Scenario
• I've inherited some
legacy code
• It's valuable
• I need to add feature
• Or fix bug
• How can I ever break
out of this loop?
Need to
change
the code
No tests
Not
designed
for testing
Needs
refactoring
to add
tests
Can’t
refactor
without
tests
33. 33
Thoughts on Golden Master Tests
• Good to start testing legacy systems
• Good when goal is to keep behaviour unchanged
• Depends on ease of:
– Capturing output
– Getting stable output
– Reviewing any differences
– Avoiding overwriting Golden Master by mistake!
36. 36
One approach: “ApprovalTests”
• Llewellyn Falco’s convenient, powerful, flexible Golden Master implementation
• Supports many language
And now C++!
GO
Java
Lua
.NET
.Net.ASP
NodeJS
Objective-C
PHP
Perl
Python
Swift
37. 37
ApprovalTests.cpp – Approval Tests for C++
• New C++ library for applying Llewellyn Falco’s “Approval Tests” approach
• For testing cross-platform C++ code (Windows, Linux, Mac)
• For legacy and green-field systems
39. 39
ApprovalTests.cpp
• C++11 and above
• Works with a range of testing frameworks
• Currently supports Google Test, Catch1, Catch2 and doctest
– Easy to add more
43. 43
Naming Options
• About that version number in the download (ApprovalTests.v.5.1.0.hpp)…
• Could just call it by your preferred convention:
ApprovalTests.h
ApprovalTests.hpp
• Or use a wrapper and keep the version number, e.g.:
// In ApprovalTests.hpp
#include "ApprovalTests.v.5.1.0.hpp"
45. 45
How to use it: Catch2 Boilerplate
• Your main.cpp
// main.cpp:
#define APPROVALS_CATCH
#include "ApprovalTests.hpp"
46. 46
How to use it: Catch2 Boilerplate
• Your main.cpp
// main.cpp:
#define APPROVALS_CATCH
#include "ApprovalTests.hpp"
47. 47
How to use it: Catch2 Boilerplate
• Your main.cpp
// main.cpp:
#define APPROVALS_CATCH
#include "ApprovalTests.hpp"
48. 48
Pure Catch2 Test
• A test file
#include "Catch.hpp"
// Catch-only test
TEST_CASE( "Sums are calculated" )
{
REQUIRE( 1 + 1 == 2 );
REQUIRE( 1 + 2 == 3 );
}
49. 49
Pure Catch2 Test
• A test file
#include "Catch.hpp"
// Catch-only test
TEST_CASE( "Sums are calculated" )
{
REQUIRE( 1 + 1 == 2 );
REQUIRE( 1 + 2 == 3 );
}
50. 50
Pure Catch2 Test
• A test file
#include "Catch.hpp"
// Catch-only test
TEST_CASE( "Sums are calculated" )
{
REQUIRE( 1 + 1 == 2 );
REQUIRE( 1 + 2 == 3 );
}
51. 51
Approvals Catch2 Test
• A test file (Test02.cpp)
#include "ApprovalTests.hpp"
#include "Catch.hpp"
// Approvals test - test static value, for demo purposes
TEST_CASE( "TestFixedInput" )
{
Approvals::verify("SomenMulti-linenoutput");
}
52. 52
Approvals Catch2 Test
• A test file (Test02.cpp)
#include "ApprovalTests.hpp"
#include "Catch.hpp"
// Approvals test - test static value, for demo purposes
TEST_CASE( "TestFixedInput" )
{
Approvals::verify("SomenMulti-linenoutput");
}
53. 53
Approvals Catch2 Test
• A test file (Test02.cpp)
#include "ApprovalTests.hpp"
#include "Catch.hpp"
// Approvals test - test static value, for demo purposes
TEST_CASE( "TestFixedInput" )
{
Approvals::verify("SomenMulti-linenoutput");
}
54. 54
Approvals Catch2 Test
• A test file (Test02.cpp)
#include "ApprovalTests.hpp"
#include "Catch.hpp"
// Approvals test - test static value, for demo purposes
TEST_CASE( "TestFixedInput" )
{
Approvals::verify("SomenMulti-linenoutput");
}
55. 55
First run
• 1.
• 2. Differencing tool pops up (Araxis Merge, in this case)
60. 60
Second run
• I approved the output
• Then we commit the test, and the approved file(s) to version control
• The diffing tool only shows up if:
– there is not (yet) an Approved file or
– the Received differs from the Approved
61. 61
What’s going on here?
• It’s a very convenient form of Golden Master testing
• Objects being tested are stringified
• Captures snapshot of current behaviour
62. 62
Approval Tests versus Test Framework?
• Think of Approval Tests as an addition to your test framework
• … easy way to test larger, more complex things
• Useful for locking down behaviour of existing code
– Combines with your testing framework
– Not intended to replace Unit Tests
65. 65
How to use it: Catch2 Boilerplate
• Your main.cpp
#define APPROVALS_CATCH
#include "ApprovalTests.hpp"
// Put all approved and received files in "approval_tests/"
auto dir =
Approvals::useApprovalsSubdirectory("approval_tests");
73. 73
Applying this to legacy code
TEST_CASE("New test of legacy feature")
{
// Standard pattern:
// Wrap your legacy feature to test in a function call
// that returns some state that can be written to a text file
// for verification:
const LegacyThing result = doLegacyOperation();
Approvals::verify(result);
}
75. 75
Feature: Consistency over machines
• Naming of output files
• Approved files version controlled
• Caution! Renaming test files or test names requires renaming .approved files
76. 76
1 rn
2 rn
3 rn
4 rn
5 rn
Feature: Consistency over Operating Systems
• Line-endings
1 r
2 r
3 r
4 r
5 r
=
77. 77
Feature: Consistency over Languages
• Consistent concepts and nomenclature in all the implementations
.NET:
Approvals.Verify("should be
approved");
C++:
Approvals::verify("should be
approved");
Python:
verify("should be approved")
81. 81
All values in single output file:
Test Squares
0 => 0
1 => 1
2 => 4
3 => 9
82. 82
Feature: Quick to get good coverage
TEST_CASE("verifyAllCombinationsWithLambda")
{
std::vector<std::string> strings{"hello", "world"};
std::vector<int> numbers{1, 2, 3};
CombinationApprovals::verifyAllCombinations(
// Lambda that acts on one combination of inputs, and returns the result to be approved:
[](std::string s, int i) { return s + " " + std::to_string(i); },
strings, // The first input container
numbers); // The second input container
}
83. 83
Feature: Quick to get good coverage
TEST_CASE("verifyAllCombinationsWithLambda")
{
std::vector<std::string> strings{"hello", "world"};
std::vector<int> numbers{1, 2, 3};
CombinationApprovals::verifyAllCombinations(
// Lambda that acts on one combination of inputs, and returns the result to be approved:
[](std::string s, int i) { return s + " " + std::to_string(i); },
strings, // The first input container
numbers); // The second input container
}
84. 84
Feature: Quick to get good coverage
TEST_CASE("verifyAllCombinationsWithLambda")
{
std::vector<std::string> strings{"hello", "world"};
std::vector<int> numbers{1, 2, 3};
CombinationApprovals::verifyAllCombinations(
// Lambda that acts on one combination of inputs, and returns the result to be approved:
[](std::string s, int i) { return s + " " + std::to_string(i); },
strings, // The first input container
numbers); // The second input container
}
85. 85
Feature: Quick to get good coverage
TEST_CASE("verifyAllCombinationsWithLambda")
{
std::vector<std::string> strings{"hello", "world"};
std::vector<int> numbers{1, 2, 3};
CombinationApprovals::verifyAllCombinations(
// Lambda that acts on one combination of inputs, and returns the result to be approved:
[](std::string s, int i) { return s + " " + std::to_string(i); },
strings, // The first input container
numbers); // The second input container
}
86. 86
All values in single output file:
(hello, 1) => hello 1
(hello, 2) => hello 2
(hello, 3) => hello 3
(world, 1) => world 1
(world, 2) => world 2
(world, 3) => world 3
87. 87
Tip: To String docs
• https://github.com/approvals/ApprovalTests.cpp/blob/master/doc/ToString.md#top
91. 91
Customisability: Reporters
• Reporters act on test failure
• Very simple but powerful abstraction
• Gives complete user control over how to inspect and act on a failure
– If you adopt Approvals, do review the supplied reporters for ideas
92. 92
“Disposable Objects” (RAII)
TEST_CASE("DisposableExplanation")
{
{
auto disposer = Approvals::useAsDefaultReporter(
std::make_shared<Mac::BeyondCompareReporter>());
// Your tests here will use Mac::BeyondCompareReporter...
} // as soon as the code passes this }, the disposer is destroyed
// and Approvals reinstates the previous default reporter
}
93. 93
“Disposable Objects” (RAII)
TEST_CASE("DisposableExplanation")
{
{
auto disposer = Approvals::useAsDefaultReporter(
std::make_shared<Mac::BeyondCompareReporter>());
// Your tests here will use Mac::BeyondCompareReporter...
} // as soon as the code passes this }, the disposer is destroyed
// and Approvals reinstates the previous default reporter
}
94. 94
“Disposable Objects” (RAII)
TEST_CASE("DisposableExplanation")
{
{
auto disposer = Approvals::useAsDefaultReporter(
std::make_shared<Mac::BeyondCompareReporter>());
// Your tests here will use Mac::BeyondCompareReporter...
} // as soon as the code passes this }, the disposer is destroyed
// and Approvals reinstates the previous default reporter
}
95. 95
“Disposable Objects” (RAII)
TEST_CASE("DisposableExplanation")
{
{
auto disposer = Approvals::useAsDefaultReporter(
std::make_shared<Mac::BeyondCompareReporter>());
// Your tests here will use Mac::BeyondCompareReporter...
} // as soon as the code passes this }, the disposer is destroyed
// and Approvals reinstates the previous default reporter
}
96. 96
“Disposable Objects”
• Hold on to the object returned by a customization ([[nodiscard]])
• Most customizations are reversible
• Gives good, fine-grained control
97. 97
Feature: Change the default Reporter
// main.cpp - or in individual tests:
#include <memory>
auto disposer = Approvals::useAsDefaultReporter(
std::make_shared<Windows::AraxisMergeReporter>() );
auto disposer = Approvals::useAsDefaultReporter(
std::make_shared<GenericDefaultReporter>(
"E:/Program Files/Araxis/Araxis Merge/Compare.exe") );
98. 98
Feature: Change the default Reporter
// main.cpp - or in individual tests:
#include <memory>
auto disposer = Approvals::useAsDefaultReporter(
std::make_shared<Windows::AraxisMergeReporter>() );
auto disposer = Approvals::useAsDefaultReporter(
std::make_shared<GenericDefaultReporter>(
"E:/Program Files/Araxis/Araxis Merge/Compare.exe") );
99. 99
Feature: Change the default Reporter
// main.cpp - or in individual tests:
#include <memory>
auto disposer = Approvals::useAsDefaultReporter(
std::make_shared<Windows::AraxisMergeReporter>() );
auto disposer = Approvals::useAsDefaultReporter(
std::make_shared<GenericDefaultReporter>(
"E:/Program Files/Araxis/Araxis Merge/Compare.exe") );
100. 100
Feature: Change the Reporter in one test
TEST_CASE("Demo custom reporter")
{
std::vector<std::string> v{"hello", "world"};
Approvals::verifyAll(v, MyCustomReporter{});
}
101. 101
Feature: Doesn’t run GUIs on build servers
• Since v5.1.0
• Any failing tests will still fail
• No diff tool pops up
• Concept: “Front loaded reporters”
102. 102
Feature: Convention over Configuration
• Fewer decisions for developers
• Users only specify unusual behaviours
107. 107
Challenge: Multiple output files per test
• Naming convention generates one file name per test
• Multiple verify calls in one test case just overwrite same file
• Multiple options provided for this:
• https://github.com/approvals/ApprovalTests.cpp/blob/master/doc/MultipleOutputFilesPerTest.md
108. 108
Flexibility gives non-obvious power
• Approval Tests approach is deceptively simple
• Reporter could:
– Convert numbers to picture for comparison
– Or Excel file
• ApprovalWriter could:
– Write out a hash value of contents of very large file
113. 113
What I’m aiming for
TEST(PolyhedralGraphicsStyleApprovals, CUVXAT)
{
// CUVXAT identifies a crystal structure in the database
const QImage crystal_picture = loadEntry("CUVXAT");
verifyQImage(crystal_picture);
}
124. 124
Create custom QImage comparison class
• Solution: Allow differences in up to 1/255 of RGB values at each pixel
• Not visible to the human eye.
• https://github.com/approvals/ApprovalTests.cpp/blob/master/doc/CustomComparators.md
125. 125
Does the difference matter?
• Legacy code is often brittle
• Testing makes changes visible
• Then decide if change matters
• Fast feedback cycle for efficient development
126. 126
Looking back
• User perspective
• Learned about own code
• Enabled unit tests for graphics problems
• Approvals useful even for temporary tests
132. 132
Want to know more?
• Monday: Beverages with Backtrace
• Tuesday: Tool Time – try it out!
133. 133
Thank You
• Clare Macrae Consulting Ltd
– https://claremacrae.co.uk/
– clare@claremacrae.co.uk
• Slides, Example Code
– https://claremacrae.co.uk/conferences/presentations.html
• ApprovalTests.cpp
– https://github.com/approvals/ApprovalTests.cpp
– https://github.com/approvals/ApprovalTests.cpp.StarterProject
Please connect on
Notes de l'éditeur
I’ll tweet the location of slides (and perhaps sample code) later today
For the last 30 years I worked at Cambridge Crystallographic Data Centre
And for 20 of those, I was mostly a C++ programmer
In recent years I was more management – but I did miss the programming
Last year I got the chance to work on a graphics project
So let me give you some context of what we are looking at here
These kinds of diagrams will probably be familiar to many from school chemistry lessons
They say there are Carbon, Oxygen and Hydrogen atoms in this “molecule”.
The lines represent the bonds between the atoms.
There are lots of conventions built in to this, such as the hydrogens here are collapsed down to the labels.
Chemistry software uses standard conventions for to colour-code by element. So Oxygen is often drawn as red.
We can see some kind of shape here, but what really is the shape of the sucrose molecule.
Now by changing the display style, we start to see more of the shape.
In fact, a crystal of sucrose is built from a potentially infinite set of repeating molecules.
This change is going to ripple through everything – I want to tell you my story of the techniques I used to do this safely and effectively.
The remote pairing has been key to this
Several hours a month on average – screenshare and video conference calls.
Another phrase for the Golden Master output is “Known Good” output.
All example code here has using namespace
One thing to note when looking at example code – ignore the headers – you’ll be using the single header e.g. “ApprovalTests.hpp”
You can create approval files with thousands of lines of output – thousands of test cases
Diff tools make it easy to understand future failures