Testing Flutter Applications

Testing Classes, State, and Edge Cases

15 min Lesson 3 of 12

Testing Classes, State, and Edge Cases

Writing tests for individual functions is a good start, but real applications are built from classes that hold state and expose methods that interact with each other. In this lesson you will learn how to use group() to organise related tests, how to test stateful Dart classes thoroughly, and how to systematically cover the edge cases — null inputs, boundary values, and error paths — that are most likely to contain bugs.

Organising Tests with group()

The group() function from the flutter_test / test package lets you nest related tests under a shared description. Groups can be nested inside other groups, and each group can have its own setUp() and tearDown() callbacks that run before and after every test within that group.

Using group() to Structure a Test File

import 'package:test/test.dart';
import 'package:my_app/models/cart.dart';

void main() {
  group('Cart', () {
    late Cart cart;

    setUp(() {
      // Runs before EVERY test in this group
      cart = Cart();
    });

    group('addItem', () {
      test('increases item count by 1', () {
        cart.addItem(Item(id: '1', price: 9.99));
        expect(cart.itemCount, equals(1));
      });

      test('accumulates total price correctly', () {
        cart.addItem(Item(id: '1', price: 9.99));
        cart.addItem(Item(id: '2', price: 4.50));
        expect(cart.total, closeTo(14.49, 0.001));
      });
    });

    group('removeItem', () {
      test('decreases item count by 1', () {
        cart.addItem(Item(id: '1', price: 9.99));
        cart.removeItem('1');
        expect(cart.itemCount, equals(0));
      });

      test('throws StateError when item is not in cart', () {
        expect(
          () => cart.removeItem('nonexistent'),
          throwsA(isA<StateError>()),
        );
      });
    });
  });
}
Note: Each setUp() creates a fresh Cart instance, so no test can accidentally pollute the state seen by another test. Always reset mutable objects in setUp() rather than at the top of the file.

Testing Stateful Classes

A stateful class maintains internal data that changes as its methods are called. To test it reliably you need to:

  • Verify the initial state immediately after construction.
  • Test each transition: call a method and assert the resulting state.
  • Test sequences of calls to catch interactions between methods.
  • Assert that side effects (events emitted, listeners called, streams updated) occur.

Testing a Stateful Counter Class

// The class under test
class Counter {
  int _value;
  final int min;
  final int max;

  Counter({this.min = 0, this.max = 10}) : _value = min;

  int get value => _value;

  void increment() {
    if (_value < max) _value++;
  }

  void decrement() {
    if (_value > min) _value--;
  }

  void reset() => _value = min;
}

// Test file
import 'package:test/test.dart';

void main() {
  group('Counter', () {
    group('initial state', () {
      test('starts at min value', () {
        final c = Counter(min: 3, max: 10);
        expect(c.value, equals(3));
      });

      test('defaults min=0, max=10', () {
        final c = Counter();
        expect(c.value, equals(0));
      });
    });

    group('increment', () {
      test('increases value by 1', () {
        final c = Counter();
        c.increment();
        expect(c.value, equals(1));
      });

      test('does not exceed max', () {
        final c = Counter(max: 2);
        c.increment();
        c.increment();
        c.increment(); // should be clamped
        expect(c.value, equals(2));
      });
    });

    group('reset', () {
      test('restores value to min', () {
        final c = Counter(min: 5, max: 20);
        c.increment();
        c.increment();
        c.reset();
        expect(c.value, equals(5));
      });
    });
  });
}

Covering Edge Cases Systematically

Edge cases are the inputs or conditions at the boundary of valid and invalid behaviour. They are responsible for a disproportionate share of production bugs. A practical checklist for every method:

  • Null / absent input — what happens when an optional argument is omitted or an object reference is null?
  • Boundary values — test exactly at the limit (e.g., max), one below (max - 1), and one above (max + 1).
  • Empty collections — empty list, empty string, zero-length map.
  • Negative numbers — when the domain is supposed to be positive-only.
  • Error paths — confirm that exceptions or Error objects are thrown with meaningful messages when preconditions are violated.

Edge Case Tests for a Password Validator

// Validator under test
class PasswordValidator {
  static const int minLength = 8;

  String? validate(String? password) {
    if (password == null || password.isEmpty) {
      return 'Password must not be empty';
    }
    if (password.length < minLength) {
      return 'Password must be at least $minLength characters';
    }
    if (!password.contains(RegExp(r'[A-Z]'))) {
      return 'Password must contain an uppercase letter';
    }
    return null; // valid
  }
}

// Tests
void main() {
  final validator = PasswordValidator();

  group('PasswordValidator', () {
    // Null / empty edge cases
    test('returns error for null password', () {
      expect(validator.validate(null), isNotNull);
    });

    test('returns error for empty string', () {
      expect(validator.validate(''), isNotNull);
    });

    // Boundary values
    test('rejects password of exactly minLength - 1 chars', () {
      final short = 'Abcdef1'; // 7 chars
      expect(validator.validate(short), isNotNull);
    });

    test('accepts password of exactly minLength chars', () {
      final exact = 'Abcdef12'; // 8 chars
      expect(validator.validate(exact), isNull);
    });

    // Error path
    test('returns error when no uppercase letter is present', () {
      expect(validator.validate('abcdefgh'), isNotNull);
    });

    // Happy path
    test('returns null for a fully valid password', () {
      expect(validator.validate('Secure#99'), isNull);
    });
  });
}
Tip: Use equivalence partitioning to avoid redundant tests. All inputs that should produce the same outcome form a partition. Pick one representative from each partition plus all boundary values between partitions — that gives maximum coverage with minimum tests.
Warning: Do not test only the happy path. A test suite that passes 100% of the time because it only exercises valid inputs gives a false sense of security. Always write at least one test for each expected failure mode.

Testing Async State Changes

Many real classes load data asynchronously. Wrap such tests in async / await and use expectLater with stream matchers when the class exposes a Stream or ValueNotifier.

Summary

Well-structured tests use group() to mirror the shape of the code being tested. For every class, verify the initial state, each method transition, meaningful sequences, and all edge cases: null inputs, boundary values, empty collections, and error paths. Consistent use of setUp() keeps tests independent and reliable. This discipline transforms testing from an afterthought into a design tool that improves your code before it ships.