Module 18: Testing -- Proving Your Code Works

Production Polish

A developer with a safety net below them while building on a tightrope of code

🎯

Teach: How to write unit tests for Dart classes and widget tests for Flutter widgets using the test and flutter_test packages. See: Complete test files with group/setUp/expect patterns and WidgetTester interactions. Feel: That testing is not a chore but a safety net that lets you move faster with confidence.

🎙️

Let me tell you about the two kinds of developers. The first kind writes code, manually taps through the app to check if it works, ships it, and prays. The second kind writes tests that automatically verify the code works, runs those tests in two seconds, and ships with confidence. Both developers have bugs. But the second developer finds them at 2 PM on a Tuesday instead of 2 AM on a Saturday when a customer calls. Testing is not about writing perfect code. It is about catching mistakes before your users do.

Why Testing Matters

🎯

Teach: Why automated tests are essential for confident refactoring, regression prevention, and living documentation. See: The three levels of Flutter testing (unit, widget, integration) with their speed, scope, and purpose compared. Feel: Convinced that testing is a time-saver, not a time-waster -- it catches bugs before users do.

You might be thinking: "I can just run the app and tap around to check if it works." That works when your app has one screen and three buttons. It stops working when you have fifteen screens, shared state, and edge cases you forgot about.

Automated tests give you three superpowers:

  1. Confidence to refactor. Want to rewrite that messy function? Run the tests. If they pass, your rewrite works. If they fail, you broke something and you know exactly what.

  2. Protection against regressions. You fix a bug on Monday. Without a test, you might reintroduce the same bug on Thursday. With a test, it is impossible -- the test would fail.

  3. Documentation. Tests describe what your code is supposed to do. A test named "sortByPriority returns tasks with priority 1 first" tells you exactly what that method does without reading the implementation.

Flutter has three levels of testing:

Level Speed Scope What it tests
Unit tests Milliseconds Single function or class Logic, calculations, data transformations
Widget tests Seconds Single widget UI rendering, user interaction, state changes
Integration tests Minutes Full app on device End-to-end user flows

This module focuses on unit tests and widget tests. They are fast, reliable, and cover the vast majority of what you need to verify.

🎙️

Developers who don't test argue that tests slow them down. In the short term, they're right — writing a test takes more time than not writing it. But in the medium and long term, that logic inverts hard. The time saved by not writing tests gets spent, with interest, debugging regressions, manually retesting after every change, and fearfully tiptoeing around refactors. Tests let you move fast without breaking things. The apps I've seen ship with confidence all had robust test suites. The apps that turned into slow-moving fragile messes had none. Start treating tests as a non-negotiable part of your craft, not an optional extra.

Unit Tests with the test Package

🎯

Teach: The core unit testing pattern: arrange, act, assert using test(), group(), setUp(), and expect(). See: A complete test file for a Calculator class covering happy paths and error cases. Feel: That writing a test is as natural as writing the code it tests.

Unit tests live in the test/ directory and test pure Dart logic -- no Flutter widgets involved.

// lib/models/calculator.dart
class Calculator {
  double add(double a, double b) => a + b;
  double divide(double a, double b) {
    if (b == 0) throw ArgumentError('Cannot divide by zero');
    return a / b;
  }
}
// test/calculator_test.dart
import 'package:test/test.dart';
import 'package:my_app/models/calculator.dart';

void main() {
  late Calculator calculator;

  setUp(() {
    calculator = Calculator();
  });

  group('Calculator', () {
    test('add returns the sum of two numbers', () {
      expect(calculator.add(2, 3), equals(5));
    });

    test('add handles negative numbers', () {
      expect(calculator.add(-1, -2), equals(-3));
    });

    test('divide returns the quotient', () {
      expect(calculator.divide(10, 2), equals(5));
    });

    test('divide by zero throws ArgumentError', () {
      expect(() => calculator.divide(10, 0), throwsArgumentError);
    });
  });
}
🎙️

Every test follows the same three-step rhythm: arrange, act, assert. Arrange: set up the objects you need. Act: call the method you are testing. Assert: verify the result matches what you expected. In the example above, setUp handles the arrange step by creating a fresh Calculator before each test. The method call inside the test is the act step. And expect is the assert step. Once you internalize this rhythm, writing tests becomes almost mechanical.

Key Test Functions

  • test('description', () { ... }) -- defines a single test case
  • group('name', () { ... }) -- groups related tests together (shows up as a nested section in test output)
  • setUp(() { ... }) -- runs before each test in the current group
  • tearDown(() { ... }) -- runs after each test (for cleanup)
  • expect(actual, matcher) -- the assertion that makes or breaks a test

Common Matchers

Matchers are the right side of expect(). They describe what you expect the actual value to look like:

// Equality
expect(value, equals(42));
expect(value, isNull);
expect(value, isNotNull);
expect(value, isTrue);
expect(value, isFalse);

// Collections
expect(list, contains('item'));
expect(list, hasLength(3));
expect(list, isEmpty);

// Comparison
expect(value, greaterThan(5));
expect(value, lessThanOrEqualTo(10));

// Type checking
expect(value, isA<String>());

// Exceptions
expect(() => riskyFunction(), throwsException);
expect(() => riskyFunction(), throwsA(isA<ArgumentError>()));
expect(() => riskyFunction(), throwsArgumentError);

Test Organization and Naming

Good test names describe the behavior, not the implementation:

// Bad: describes the implementation
test('calls removeWhere on the list', () { ... });

// Good: describes the behavior
test('deleteTask removes the task from the list', () { ... });

Group tests by the method or behavior they cover:

group('sortByPriority', () {
  test('returns tasks with priority 1 first', () { ... });
  test('returns empty list for empty input', () { ... });
  test('preserves order for equal priorities', () { ... });
});

Running Tests

# Run all tests
flutter test

# Run a specific test file
flutter test test/calculator_test.dart

# Run with verbose output
flutter test --reporter expanded

Widget Tests with flutter_test

🎯

Teach: How to render widgets in a test environment and verify their appearance and behavior using WidgetTester, find, and expect. See: A complete widget test that pumps a widget, simulates a tap, rebuilds, and asserts the UI updated correctly. Feel: That widget tests follow the same arrange-act-assert rhythm as unit tests and are surprisingly fast.

🎙️

Unit tests verify that your Dart logic is correct. But what about the UI? Does the button actually appear on screen? Does tapping the checkbox change the state? Does the title show up with the right text? That is what widget tests do. They render your widget in a lightweight test environment -- no phone or emulator needed -- and let you inspect and interact with it programmatically. Widget tests are faster than you might expect. They run in milliseconds, not seconds.

Widget tests use the flutter_test package (included in every Flutter project by default) and the WidgetTester helper.

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/widgets/counter_widget.dart';

void main() {
  testWidgets('Counter increments when button is tapped',
      (WidgetTester tester) async {
    // Arrange: render the widget
    await tester.pumpWidget(
      const MaterialApp(home: CounterWidget()),
    );

    // Assert: verify initial state
    expect(find.text('0'), findsOneWidget);
    expect(find.text('1'), findsNothing);

    // Act: tap the button
    await tester.tap(find.byIcon(Icons.add));
    await tester.pump(); // Rebuild after state change

    // Assert: verify updated state
    expect(find.text('1'), findsOneWidget);
    expect(find.text('0'), findsNothing);
  });
}
🎯

Teach: The WidgetTester workflow -- pumpWidget to render, find to locate, tap/enterText to interact, pump to rebuild, expect to verify. See: A complete widget test that simulates user interaction and verifies state changes. Feel: That widget tests are approachable -- they follow the same arrange-act-assert pattern as unit tests.

Finding Widgets

The find object is your search tool. It locates widgets in the rendered tree:

find.text('Hello');              // Find by text content
find.byType(ElevatedButton);    // Find by widget type
find.byIcon(Icons.add);         // Find by icon
find.byKey(const Key('myKey')); // Find by Key
find.byWidgetPredicate(
  (w) => w is Text && w.data == 'Hi'
);                               // Find by custom predicate

Widget Matchers

After finding a widget, verify its existence:

findsOneWidget          // Exactly one widget found
findsNothing            // No widgets found
findsNWidgets(3)        // Exactly 3 widgets found
findsAtLeastNWidgets(1) // At least 1 widget found

Interacting with Widgets

WidgetTester can simulate taps, text entry, drags, and long presses:

await tester.tap(find.byType(ElevatedButton));
await tester.enterText(find.byType(TextField), 'Hello');
await tester.drag(find.byType(ListView), const Offset(0, -200));
await tester.longPress(find.byType(ListTile));

After every interaction that changes state, call one of:

await tester.pump();           // Rebuild once (good for setState)
await tester.pumpAndSettle();  // Rebuild until all animations complete

Testing Callbacks

To verify a widget calls its callback when tapped, use a flag variable:

testWidgets('calls onPressed when tapped', (tester) async {
  bool wasPressed = false;

  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: MyButton(
          onPressed: () => wasPressed = true,
        ),
      ),
    ),
  );

  await tester.tap(find.byType(MyButton));
  expect(wasPressed, isTrue);
});

Testing State Changes

To verify that state changes update the UI correctly:

testWidgets('checkbox toggles task completion', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: TaskTile(
          task: Task(id: '1', title: 'Test', isCompleted: false),
          onToggle: () {},
          onDelete: () {},
        ),
      ),
    ),
  );

  // Find the Checkbox widget and verify it is unchecked
  final checkbox = tester.widget<Checkbox>(find.byType(Checkbox));
  expect(checkbox.value, isFalse);
});

The Key to Widget Keys

When you have multiple widgets of the same type (like multiple delete buttons), find.byType returns all of them. Use Key to distinguish between them:

// In the widget
IconButton(
  key: Key('delete_${task.id}'),
  icon: const Icon(Icons.delete),
  onPressed: onDelete,
)

// In the test
await tester.tap(find.byKey(const Key('delete_1')));

Mocking -- Faking Dependencies

🎯

Teach: How to create mock implementations of services so tests run without real network calls or databases. See: A MockApiService that returns controlled responses, letting you test success, failure, and edge cases deterministically. Feel: That mocking is a practical technique, not an abstract concept -- it makes tests fast, reliable, and independent.

When your code depends on an external service (API, database), you do not want your tests calling real servers. Create mock implementations:

abstract class ApiService {
  Future<String> fetchData();
}

class MockApiService implements ApiService {
  String? mockResponse;
  bool shouldThrow = false;

  @override
  Future<String> fetchData() async {
    if (shouldThrow) throw Exception('Network error');
    return mockResponse ?? 'default';
  }
}

// In the test
test('handles API response', () async {
  final mockApi = MockApiService();
  mockApi.mockResponse = 'test data';

  final result = await mockApi.fetchData();
  expect(result, equals('test data'));
});
🎙️

Mocking is like using a stunt double in a movie. You do not throw your actual star off a building to film an action scene. You use a stunt double that looks like the star but is specifically designed for the test -- I mean, the stunt. Similarly, you do not call a real API in your tests. You use a mock that behaves exactly how you tell it to, so you can test your code's reaction to success, failure, slow responses, and empty data without any network dependency.

🔄

Where this fits: Testing validates everything you have built in this course. Unit tests verify your models (Module 3 Dart OOP), your state management logic (Module 13 Provider), and your service layer. Widget tests verify your custom widgets (Module 15) and screen layouts. A well-tested app is one you can confidently extend and refactor.

There Are No Dumb Questions

🎯

Teach: Answers to common testing questions about what to test, MaterialApp wrapping, pump vs. pumpAndSettle, file organization, and async tests. See: Practical guidance on the testing decisions and gotchas that trip up beginners most often. Feel: Reassured that testing has clear conventions and that the confusing parts have simple explanations.

Q: Do I need to test every single widget?

A: No. Focus on widgets that have logic -- conditional rendering, state changes, callbacks. A widget that just displays a Text in a Card is probably not worth testing individually. But a widget with a checkbox that toggles state, a conditional loading spinner, or complex conditional layout? Test those.

Q: Why do I need MaterialApp in pumpWidget?

A: Many Flutter widgets depend on inherited widgets provided by MaterialApp -- things like Theme, MediaQuery, Navigator, and Directionality. Without MaterialApp, widgets that use Theme.of(context) or Navigator.of(context) will throw errors. Always wrap your test widget in MaterialApp.

Q: What is the difference between pump() and pumpAndSettle()?

A: pump() triggers a single frame rebuild. Use it after setState calls. pumpAndSettle() keeps pumping frames until there are no more scheduled frames -- essentially waiting for all animations to complete. Use pumpAndSettle() when your widget has animations (like AnimatedContainer or Hero transitions).

Q: Should tests be in the same file as the code they test?

A: No. Tests go in the test/ directory, mirroring the structure of lib/. If your model is at lib/models/task.dart, your tests go at test/task_test.dart or test/models/task_test.dart.

Q: How do I test async code?

A: Mark your test callback as async and use await:

test('fetches data successfully', () async {
  final result = await myService.fetchData();
  expect(result, isNotNull);
});

For widget tests with async operations, use await tester.pumpAndSettle() to wait for async rebuilds.

🎙️

Two pieces of advice worth keeping in mind as you write tests. First, prioritize ruthlessly. A test that verifies Text widgets render on screen adds almost nothing — if you broke that, you'd notice in two seconds by running the app. A test that verifies the price totals correctly when a user adds a discounted item to a cart? That's invaluable, because the bug is subtle and the fix is non-obvious. Aim your tests at logic that's hard to eyeball. Second, write tests before the code gets complicated. If you start testing a 500-line file, you'll hate it. If you add tests as each 50-line unit goes in, testing stays sustainable.

📝 Module Quiz

Test what you learned. 45 seconds per question. Passing score is set per module. Your best attempt is saved in your browser so you can track progress -- nothing is sent to a server.

Sharpen Your Pencil

🎯

Teach: How to write comprehensive unit and widget tests by building a Task model, TaskTile widget, and TaskListScreen with full test coverage. See: Three exercises that progress from pure Dart unit tests to interactive widget tests to full screen tests. Feel: Equipped to write meaningful tests for your own projects using the patterns practiced here.

Exercise 1: Task Model Unit Tests

Create a Task class with id, title, description, isCompleted (default false), priority (1-3), and createdAt. Include copyWith, toggleCompleted, static sortByPriority, filterCompleted, filterPending, and equality override based on id.

Then write comprehensive unit tests covering:

  • Task creation -- defaults are correct, all fields are set
  • copyWith -- no-arg returns equal task, single-field changes only that field, other fields preserved
  • toggleCompleted -- pending becomes completed, completed becomes pending, other fields unchanged
  • sortByPriority -- priority 1 first, empty list returns empty, same-priority preserved
  • filterCompleted / filterPending -- correct filtering, empty list, all-completed list
  • equality -- same id equals, different id not equals

Exercise 2: Widget Tests for TaskTile

Create a TaskTile StatelessWidget that shows a Checkbox, the task title (strikethrough if completed), a priority color indicator, and a delete IconButton. Use widget keys: Key('checkbox_${task.id}') and Key('delete_${task.id}').

Then write widget tests for:

  • Displays the task title text
  • Shows checkbox unchecked for pending task
  • Shows checkbox checked for completed task
  • Calls onToggle when checkbox is tapped
  • Calls onDelete when delete button is tapped
  • Shows strikethrough text decoration for completed task
  • Shows correct priority color (red for 1, orange for 2, green for 3)

Exercise 3: Screen Widget Tests

Create a TaskListScreen with 3 sample tasks, a ListView of TaskTile widgets, and a FloatingActionButton that adds a new task. Write tests that verify:

  • All 3 initial task titles are visible
  • Tapping the FAB increases the visible task count to 4
  • Tapping a delete button decreases the count to 2
  • Tapping a checkbox toggles its checked state
🎙️

Three exercises, three layers of the testing pyramid. The Task model is pure logic — unit tests catch every edge case in milliseconds. The TaskTile is a leaf widget — widget tests verify its rendering and callbacks without the overhead of a full screen. The TaskListScreen composes them — widget tests verify the interaction between children, the FAB behavior, and state updates. When you finish all three, you'll have a test suite that runs in seconds and gives you genuine confidence. That confidence is what lets senior developers ship big refactors on Friday afternoon without losing sleep over the weekend.

💡

Write the test first, then write the code to make it pass. This is called test-driven development (TDD), and it forces you to think about what your code should do before you think about how it does it.

1 / 1