Approval Tests implementation in Flutter ๐
๐ About
Approval Tests are an alternative to assertions. Youโll find them useful for testing objects with complex values (such as long strings), lots of properties, or collections of objects.
Approval tests simplify this by taking a snapshot of the results, and confirming that they have not changed.
In normal unit testing, you say expect(person.getAge(), 5). Approvals allow you to do this when the thing that you want to assert is no longer a primitive but a complex object. Approvals.verify() accepts text; for a model with a toJson() method, use Approvals.verifyAsJson(person.toJson()).
I am writing an implementation of Approval Tests in Dart. If anyone wants to help, please text me. ๐
Thanks to Richard Coutts for special contributions to the approval_tests_flutter package.
Packages
ApprovalTests is designed for two level: Dart and Flutter.
| Package | Version | Description |
|---|---|---|
| approval_tests | Dart package for approval testing of unit tests (main) |
|
| approval_tests_flutter | Flutter package for approval testing of widget, integration tests |
๐ How it works
- The first run of the test automatically creates an
approvedfile if there is no such file. - If the changed results match the
approvedfile perfectly, the test passes. - If there's a difference, a
reportertool will highlight the mismatch and the test fails. - If the test is passed, the
receivedfile is deleted automatically. You can change this by changing thedeleteReceivedFilevalue inoptions. If the test fails, the received file remains for analysis.
Instead of writing:
testWidgets('home page', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.pumpAndSettle();
expect(find.text('You have pushed the button this many times:'), findsOneWidget);
expect(find.text('0'), findsOneWidget);
expect(find.byWidgetPredicate(
(Widget widget) => widget is Text && widget.data == 'hello' &&
widget.key == ValueKey('myKey'),
), findsOneWidget);
expect(find.text('Approved Example'), findsOneWidget);
});
Write this:
testWidgets('smoke test', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.pumpAndSettle();
await tester.approvalTest();
});
Suppose you wanted to confirm that a page loaded with all the widget you expected. To do this,
perform an approval test by calling tester.approvalTest, and give your test a suitable name:
testWidgets('home page', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.pumpAndSettle();
await tester.approvalTest(description: 'all widgets load correctly');
});
To include your project's custom widget types in your test, and to perform post-test checks, add
calls to ApprovalWidgets.setUpAll() to your tests' setUpAll calls, like so:
main() {
setUpAll(() async {
await ApprovalWidgets.setUpAll();
});
}
Each call to
approvalTest()writes a full, self-contained snapshot of the current widget tree. Calling it several times in one test produces independent snapshots (e.g. before and after a tap), and snapshot lines are sorted so the output is stable across runs.
๐งช Snapshotting the accessibility tree
approvalSemantics() captures a deterministic, geometry-free description of the
rendered semantics tree (labels, values, hints, tooltips, identifiers, and
actions). It is a strong approval artifact for accessibility coverage and reads
cleanly in a diff:
testWidgets('home screen semantics', (tester) async {
await tester.pumpWidget(const MyApp());
await tester.pumpAndSettle();
await tester.approvalSemantics(description: 'home a11y');
});
๐ผ Golden (pixel) approvals
approvalGolden() wires Flutter's native golden workflow into the same naming
convention as the text approvals, so the .png sits next to the .approved.txt
files. Create or update the approved image with flutter test --update-goldens:
testWidgets('home screen pixels', (tester) async {
await tester.pumpWidget(const MyApp());
await tester.pumpAndSettle();
await tester.approvalGolden(find.byType(MyApp), description: 'home pixels');
});
๐ฏ Matching widgets by type
Register your custom widget types to include them in approval snapshots and to
use the expectWidget / tapWidget finder helpers:
setUpAll(() async {
await ApprovalWidgets.setUpAll();
registerTypes({MyButton, MyCard});
});
tearDownAll(ApprovalWidgets.tearDownAll);
tester.expectWidget(key: MyKeys.submit, matcher: findsOneWidget);
await tester.tapWidget(intl: (_) => 'Submit');
Register from setUpAll. Registrations, the discovered class names, the intl
string holder set through WidgetTesterExtension.s, and the previous-capture
memory all belong to one capture session scoped to the
setUpAll โ tearDownAll window.
ApprovalWidgets.tearDownAll() is optional: leave it out and state persists for
the whole test process, exactly as before 1.5.0. Add it when one test file has
groups with different registrations โ without it, a registerTypes call in one
group is still in effect for every later group in that file, which changes
their snapshots.
For backwards compatibility, tapWidget() calls pumpAndSettle() after the
tap. Flutter's default settle timeout is ten minutes, so an indeterminate
animation can make a test wait much longer than intended. New tests should
choose an explicit pump policy:
// Dispatch the tap without rendering another frame.
await tester.tapWidget(
intl: (_) => 'Submit',
pumpPolicy: const WidgetActionPumpPolicy.none(),
);
// Render exactly one frame.
await tester.tapWidget(
intl: (_) => 'Submit',
pumpPolicy: const WidgetActionPumpPolicy.once(),
);
// Advance the test clock by a fixed duration and render one frame.
await tester.tapWidget(
intl: (_) => 'Submit',
pumpPolicy: const WidgetActionPumpPolicy.forDuration(
Duration(milliseconds: 300),
),
);
// Settle with an explicit frame step and upper time bound.
await tester.tapWidget(
intl: (_) => 'Submit',
pumpPolicy: const WidgetActionPumpPolicy.untilSettled(
step: Duration(milliseconds: 50),
timeout: Duration(seconds: 2),
),
);
The legacy shouldPumpAndSettle parameter is deprecated. Its default behavior
will remain unchanged throughout 1.x; use
WidgetActionPumpPolicy.none() instead of passing false. When both parameters
are supplied, pumpPolicy takes precedence.
Approval capture helpers (approvalTest, approvalSemantics, and
approvalGolden) never pump or settle implicitly.
๐ Widget-name discovery and its cache
ApprovalWidgets.setUpAll() parses your project's lib/ and collects the
public class names, so a snapshot can label a widget by its own type rather
than by the nearest framework type. Results are cached in
test/.approval_tests/class_names.txt, which is generated and belongs in
.gitignore.
The cache is validated by fingerprint, not by modification time. The stored token covers a schema revision, the package root, the Dart SDK path and version, and every scanned source's relative path, size, and timestamp. A modification-time check alone would miss three ordinary cases โ deleting a file that is not the newest, checking out a branch whose files carry older timestamps, and renaming with timestamps preserved โ and each of those would leave the cache silently stale, dropping lines from a snapshot.
Consequences worth knowing:
- A fresh CI checkout rewrites every timestamp, so CI always rescans. That is correct rather than a regression.
- Any cache problem โ missing, truncated, written by an older version, copied from another checkout โ is a rescan, never an error. A cache must not fail your test run.
- The cache is written through a temporary file and a rename.
flutter testruns test files in parallel processes that share one cache file, so a direct write is observable by another process as a truncated file. - Only the fingerprint digest is stored, never the package root or SDK path, so the file carries no absolute paths from your machine.
Why analyzer is a runtime dependency
package:analyzer sits in dependencies, not dev_dependencies, and that is
deliberate. Discovery runs inside your setUpAll and parses your sources.
Pub does not install a published package's dev dependencies for downstream
consumers, so moving analyzer there would make the import unresolvable in
every consumer's test run.
It can only move once discovery stops happening at test time โ either a
build-time step that checks in the generated name list, or a separate
_gen-style package you add as a dev dependency. Until then, note that
analyzer and _fe_analyzer_shared are the heaviest transitive dependencies
this package brings in.
๐ฆ Installation
Add the following to your pubspec.yaml file:
dependencies:
approval_tests_flutter: ^1.5.0
```
## Coverage
The 1.5.0 release has 100% line coverage for executable code under `lib`
(594/594 lines). The full suite passes all 100 test executions.
To reproduce the report locally:
```shell
flutter test --coverage
๐ Getting Started
The best way to get started is to download and open the example project:
๐ How to use
In order to use Approval Tests, the user needs to:
-
Set up a test: This involves importing the Approval Tests library into your own code.
-
Optionally, set up a reporter: Reporters are tools that highlight differences between approved and received files when a test fails. Although not necessary, they make it significantly easier to see what changes have caused a test to fail. The default reporter is the
CommandLineReporter. You can also use theDiffReporterto compare the files in your IDE, and theGitReporterto see the differences in theGit GUI. -
Manage the
approvedfile: When the test is run for the first time, an approved file is created automatically. This file will represent the expected outcome. Once the test results in a favorable outcome, the approved file should be updated to reflect these changes. A little bit below I wrote how to do it.
This setup is useful because it shortens feedback loops, saving developers time by only highlighting what has been altered rather than requiring them to parse through their entire output to see what effect their changes had.
Approving Results
Approving results just means saving the .approved.txt file with your desired results.
Weโll provide more explanation in due course, but, briefly, here are the most common approaches to do this.
โข Via Diff Tool
Most diff tools have the ability to move text from left to right, and save the result.
How to use diff tools is just below, there is a Comparator class for that.
โข Via CLI command
You can run the command in a terminal to review your files:
dart run approval_tests:review
After running the command, the files will be analyzed and you will be asked to choose one of the options:
y- Approve the received file.n- Reject the received file.view - View the differences between the received and approved files. After selectingvyou will be asked which IDE you want to use to view the differences.
The command dart run approval_tests:review has additional options, including listing files, selecting
files to review from this list by index, and more. For its current capabilities, run
dart run approval_tests:review --help
Typing 'dart run approval_tests:review' is tedious! To reduce your typing, alias the command in your
.zshrc or .bashrc file
alias review='dart run approval_tests:review'
or PowerShell profile
function review {
dart run approval_tests:review
}
โข Via approveResult property
If you want the result to be automatically saved after running the test, you need to use the approveResult property in Options:
void main() {
test('test JSON object', () {
final complexObject = {
'name': 'JsonTest',
'features': ['Testing', 'JSON'],
'version': 0.1,
};
Approvals.verifyAsJson(
complexObject,
options: const Options(
approveResult: true,
),
);
});
}
this will result in the following file
example_test.test_JSON_object.approved.txt
{
"name": "JsonTest",
"features": [
"Testing",
"JSON"
],
"version": 0.1
}
โข Via file rename
You can just rename the .received file to .approved.
Reporters
Reporters are the part of Approval Tests that launch diff tools when things do not match. They are the part of the system that makes it easy to see what has changed.
There are several reporters available in the package:
CommandLineReporter- This is the default reporter, which will output the diff in the terminal.GitReporter- This reporter will open the diff in the Git GUI.DiffReporter- This reporter will open the Diff Tool in your IDE.- For Diff Reporter I using the default paths to the IDE, if something didn't work then you in the console see the expected correct path to the IDE and specify customDiffInfo. You can also contact me for help.
To use DiffReporter you just need to add it to options:
options: const Options(
reporter: const DiffReporter(),
),
๐ Examples
I have provided a couple of small examples here to show you how to use the package.
There are more examples in the example folder for you to explore. I will add more examples in the future.
Inside, in the gilded_rose folder, there is an example of using ApprovalTests to test the legacy code of Gilded Rose kata.
You can study it to understand how to use the package to test complex code.
And the verify_methods folder has small examples of using different ApprovalTests methods for different cases.
JSON example
With verifyAsJson, if you pass data models as JsonItem, with nested other models as AnotherItem and SubItem, then you need to add an toJson method to each model for the serialization to succeed.
void main() {
const jsonItem = JsonItem(
id: 1,
name: "JsonItem",
anotherItem: AnotherItem(id: 1, name: "AnotherItem"),
subItem: SubItem(
id: 1,
name: "SubItem",
anotherItems: [
AnotherItem(id: 1, name: "AnotherItem 1"),
AnotherItem(id: 2, name: "AnotherItem 2"),
],
),
);
test('verify model', () {
Approvals.verifyAsJson(
jsonItem,
options: const Options(
deleteReceivedFile:
true, // Automatically delete the received file after the test.
approveResult:
true, // Approve the result automatically. You can remove this property after the approved file is created.
),
);
});
}
this will result in the following file
verify_as_json_test.verify_model.approved.txt
{
"jsonItem": {
"id": 1,
"name": "JsonItem",
"subItem": {
"id": 1,
"name": "SubItem",
"anotherItems": [
{
"id": 1,
"name": "AnotherItem 1"
},
{
"id": 2,
"name": "AnotherItem 2"
}
]
},
"anotherItem": {
"id": 1,
"name": "AnotherItem"
}
}
}
โ Which File Artifacts to Exclude from Source Control
You must add any approved files to your source control system. But received files can change with any run and should be ignored. For Git, add this to your .gitignore:
*.received.*
โ๏ธ For More Information
Questions?
Ask me on Telegram: @yelmuratoff.
Email: yelamanyelmuratov@gmail.com
Video Tutorials
- Getting Started with ApprovalTests.Swift
- How to Verify Objects (and Simplify TDD)
- Verify Arrays and See Simple, Clear Diffs
- Write Parameterized Tests by Transforming Sequences
- Wrangle Legacy Code with Combination Approvals
You can also watch a series of short videos about using ApprovalTests in .Net on YouTube.
Podcasts
Prefer learning by listening? Then you might enjoy the following podcasts:
Coverage
๐ค Contributing
Show some ๐ and star the repo to support the project! ๐
The project is in the process of development and we invite you to contribute through pull requests and issue submissions. ๐
We appreciate your support. ๐ซฐ
Libraries
- approval_tests_flutter
- Public entry point for Approval Tests Flutter utilities.