Writing tests

Writing tests #


Introduction #

“If you find a bug in your code, it means you have made a mistake. If your tests didn’t reveal the bug, it means you have made two mistakes.”

This chapter assumes you already know the basics of what unit and integration tests are. So let’s get right into testing in Dart and Flutter.

Unit testing #

Explanation #

We place our unit-tests in test/ folder. Each file have a main method, in which we write the tests.

To write a test we use the test function. First parameter is a description of the test. Second parameters is a function that executes the test.

We can groups tests with the group function.

We can state what we expect the outcome of the test to be using expect function. First parameter is the value we want to assert something about. Second is what we expect the value to match.

*Other languages/frameworks sometimes use the word “assert” (or variantS *thereof) instead of “expect”*

Read more:

Example #

import 'package:test/test.dart';

void main() {
  group('PushCommand', () {
    test('Pushes a value to the stack', () {
      final stack = [1, 2];
      PushCommand(3).apply(stack);
      expect(stack, [1, 2, 3]);
    });
  });

  group('AddCommand', () {
    test('Remove the top two numbers and push the result', () {
      final stack = [1, 2];
      AddCommand().apply(stack);
      expect(stack, [3]);
    });

    test('Nothing if there are less than two numbers', () {
      final stack = [1];
      final copy = [...stack];
      AddCommand().apply(stack);
      expect(stack, copy);
    });
  });
}

// Abstract Command class
abstract class Command {
  void apply(List<num> stack);
  void unapply(List<num> stack);
}

class PushCommand implements Command {
  final num value;

  PushCommand(this.value);

  @override
  void apply(List<num> stack) {
    stack.add(value);
  }

  @override
  void unapply(List<num> stack) {
    stack.removeLast();
  }
}

// Avoid duplicating logic for each operation
abstract class OperatorCommand implements Command {
  late num operand1;
  late num operand2;

  num operate(num operand1, num operand2);

  @override
  void apply(List<num> stack) {
    if (stack.length >= 2) {
      operand2 = stack.removeLast();
      operand1 = stack.removeLast();
      stack.add(operate(operand1, operand2));
    }
  }

  @override
  void unapply(List<num> stack) {
    stack.removeLast();
    stack.addAll([operand1, operand2]);
  }
}

class AddCommand extends OperatorCommand {
  @override
  num operate(num operand1, num operand2) => operand1 + operand2;
}

Ignore the warnings.

Integration testing #

Integrations tests can look very similar to widget tests. Here is a comparison table to help set them apart.

DescriptionWidgetIntegration
What gets testedwidgetthe whole app
Folder with teststest/integration_test/
Command to executeflutter testflutter test integration_test
Can you see the UI being rendered?noyes
Execution speedfastslow

Integration tests should call IntegrationTestWidgetsFlutterBinding.ensureInitialized() before executing any tests.

Let’s look at an example. First we need an app to test.

import 'package:flutter/material.dart';

void main() {
  runApp(MyCalculatorApp());
}

class MyCalculatorApp extends StatefulWidget {
  const MyCalculatorApp({super.key});

  @override
  State<MyCalculatorApp> createState() => _CalculatorAppState();
}

class _CalculatorAppState extends State<MyCalculatorApp> {
  var value = "";

  _appendDigit(String digit) {
    setState(() {
      value += digit;
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Column(
          children: [
            Text(key: Key("Display"), value),
            for (final digit in "123".characters)
              OutlinedButton(
                key: Key(digit),
                child: Text(digit),
                onPressed: () => _appendDigit(digit),
              ),
          ],
        ),
      ),
    );
  }
}

I can’t make it execute integration tests embedded in a web page. But here is some code to test it. You can try it in Android Studio.

import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
  testWidgets('Enter a number', (tester) async {
    // Build our app and trigger a frame.
    await tester.pumpWidget(const MyCalculatorApp());

    // Find widget by key and cast to `Text`
    final text =
        find.byKey(const Key("Display")).evaluate().single.widget as Text;
    // `text.data` is the string that is displayed by the text widget
    expect(text.data, equals(''));

    // Convert the number we want to enter to a string.
    // Then loop over the digits.
    for (final digit in '123'.characters) {
      // Find the corresponding button for a digit and tap it.
      await tester.tap(find.byKey(Key(digit)));
      // Trigger update
      await tester.pump();
    }

    // We now expect a widget with the text "123"
    expect(find.text("123"), findsOneWidget);
  });
}

Q: Why is a Key being used to find widgets?

A: Because after tapping “1” button, there will be two widgets with the text “1”. So we need some other way to find the correct.

Notice that Key in test have to match a Key in the app.

Helpers #

The test look above looks a bit complicated.

We can clean up the test code, making it way easier to read, by introducing some helpers in the form of extension methods.

You can read about how extension methods work here.

The parameter tester we have in our testWidgets functions are of type WidgetTester. We can add our own test specific convenience methods as extensions to the type.

extension TesterExtensions on WidgetTester {
  Future<void> enterDigits(String digits) async {
    for (var digit in digits.characters) {
      await tapByKey(Key(digit));
    }
  }

  Future<void> tapByKey(Key key) async {
    await tap(find.byKey(key));
    await pump();
  }
}

When we call find.text("123") we are invoking .text() method on an object of type CommonFinders. We can also add som extensions methods to it:

extension FinderExtensions on CommonFinders {
  String? displayText() {
    final text = byKey(const Key("Display")).evaluate().single.widget as Text;
    return text.data;
  }
}

Now the test can be rewritten as:

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('Enter a number', (tester) async {
    await tester.pumpWidget(const MyCalculatorApp());

    expect(find.displayText(), equals(''));
    await tester.enterDigits('123');
    expect(find.displayText(), equals('123'));
  });
}

Much better! Don’t you think?