Testing in Dart Like a Pro: Write Tests That Actually Matter

  • Home
  • Dart
  • Testing in Dart Like a Pro: Write Tests That Actually Matter
Front
Back
Right
Left
Top
Bottom
CONFIDENCE

The Developer Who Ships With Confidence

Here’s a confession: early in my career, I spent an entire afternoon writing JSON serialization code for 15 model classes. By hand. Individually.

Never again.

Dart’s code generation ecosystem is one of its most underappreciated superpowers. Once you understand `build_runner`, your models practically write themselves. This blog is for anyone — junior dev or senior architect — who’s tired of writing code that a machine should be writing for them.

PHILOSOPHY

The Testing Philosophy

The Three Layers

Flutter best practices recommend 70% unit tests for business logic, 20% widget tests for UI components, and 10% integration tests for critical user flows — the standard testing pyramid adapted for Flutter.

Why this ratio? Unit tests run in milliseconds — you run them constantly. Integration tests take minutes and need a device — you run them in CI. Over-investing in integration tests means slow feedback and fragile tests.

Setup

📋
# pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^5.4.4
  build_runner: ^2.4.6
  integration_test:
    sdk: flutter
UNIT TEST
Test Your Business Logic

Unit Tests

Writing Your First Unit Test

📘
// lib/services/cart_service.dart
class CartService {
  final List<String> _items = [];

  void addItem(String item) {
    if (item.isEmpty) throw ArgumentError('Item cannot be empty');
    _items.add(item);
  }

  void removeItem(String item) => _items.remove(item);
  
  int get itemCount => _items.length;
  double get total => _items.length * 9.99; // simplified
}
Run: `flutter test test/services/cart_service_test.dart`
Pro tip
Group related tests with `group()`. Use `setUp()` for shared initialization. Use `tearDown()` for cleanup. This keeps tests isolated and readable.
📘
// test/services/cart_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/services/cart_service.dart';

void main() {
  group('CartService', () {
    late CartService cartService;

    setUp(() {
      cartService = CartService(); // Fresh instance before each test
    });

    test('starts empty', () {
      expect(cartService.itemCount, equals(0));
    });

    test('adds item correctly', () {
      cartService.addItem('Flutter Book');
      expect(cartService.itemCount, equals(1));
    });

    test('throws on empty item name', () {
      expect(() => cartService.addItem(''), throwsArgumentError);
    });

    test('calculates total correctly', () {
      cartService.addItem('Item A');
      cartService.addItem('Item B');
      expect(cartService.total, equals(19.98));
    });

    test('removes item correctly', () {
      cartService.addItem('Item A');
      cartService.removeItem('Item A');
      expect(cartService.itemCount, equals(0));
    });
  });
}

Testing Asynchronous Code

📘
// lib/repositories/user_repository.dart
class UserRepository {
  final ApiClient _client;
  UserRepository(this._client);

  Future<User> fetchUser(String id) async {
    final data = await _client.get('/users/$id');
    return User.fromJson(data);
  }
}

// test/repositories/user_repository_test.dart
import 'package:flutter_test/flutter_test.dart';

void main() {
  test('fetchUser returns user on success', () async {
    final fakeClient = FakeApiClient(); // No mocking library needed for simple cases
    final repo = UserRepository(fakeClient);

    final user = await repo.fetchUser('123');

    expect(user.id, equals('123'));
    expect(user.name, equals('Alice'));
  });
}

// Simple fake — no library needed
class FakeApiClient implements ApiClient {
  @override
  Future<Map<String, dynamic>> get(String path) async {
    return {'id': '123', 'name': 'Alice', 'email': '[email protected]'};
  }
}
MOCKING

Mocking and Dependency Injection

Mockito for Powerful Mocks

For realistic scenarios — simulating errors, verifying calls, controlling async behavior — `mockito` is the industry standard.
📘
// test/repositories/user_repository_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:my_app/clients/api_client.dart';
import 'package:my_app/repositories/user_repository.dart';

import 'user_repository_test.mocks.dart'; // Generated by build_runner

@GenerateMocks([ApiClient]) // Annotation tells build_runner what to mock
void main() {
  late MockApiClient mockClient;
  late UserRepository repository;

  setUp(() {
    mockClient = MockApiClient();
    repository = UserRepository(mockClient); // Inject mock
  });

  test('fetchUser returns user when API succeeds', () async {
    // Arrange
    when(mockClient.get('/users/123')).thenAnswer((_) async => {
      'id': '123',
      'name': 'Alice',
      'email': '[email protected]',
    });

    // Act
    final user = await repository.fetchUser('123');

    // Assert
    expect(user.name, equals('Alice'));
    verify(mockClient.get('/users/123')).called(1); // Verify it was called once
  });

  test('fetchUser throws when API fails', () async {
    // Arrange — simulate network error
    when(mockClient.get('/users/999'))
        .thenThrow(Exception('Network error'));

    // Act & Assert
    expect(
      () => repository.fetchUser('999'),
      throwsException,
    );
  });
}
Generate mocks: `dart run build_runner build --delete-conflicting-outputs`

Writing Testable Code From Day One

The #1 mistake that makes code untestable: hard-coding dependencies.
📘
// Untestable — hard-coded dependency
class OrderService {
  final _repo = DatabaseOrderRepository(); // Can't swap this!

  Future<void> placeOrder(Order order) => _repo.save(order);
}

// Testable — injected dependency
class OrderService {
  final OrderRepository _repo; // Abstract interface
  OrderService(this._repo);    // Injected from outside

  Future<void> placeOrder(Order order) => _repo.save(order);
}
By injecting dependencies, your tests become fast, reliable, and completely independent of network conditions.
WIDGET
Test Your UI Without a Device

Widget Tests

Widget tests validate UI components and user interactions. They render in a virtual environment — no real screen needed — and run in seconds.
📘
// test/widgets/login_button_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/widgets/login_button.dart';

void main() {
  testWidgets('LoginButton shows loading when tapped', (tester) async {
    bool tapped = false;

    // Build the widget
    await tester.pumpWidget(MaterialApp(
      home: Scaffold(
        body: LoginButton(
          onPressed: () async {
            tapped = true;
            await Future.delayed(const Duration(seconds: 1));
          },
        ),
      ),
    ));

    // Find and tap the button
    await tester.tap(find.byType(ElevatedButton));
    await tester.pump(); // Rebuild after tap

    // Assert loading state shows
    expect(find.byType(CircularProgressIndicator), findsOneWidget);
    expect(tapped, isTrue);
  });

  testWidgets('LoginButton displays correct label', (tester) async {
    await tester.pumpWidget(MaterialApp(
      home: Scaffold(
        body: LoginButton(label: 'Sign In', onPressed: () {}),
      ),
    ));

    expect(find.text('Sign In'), findsOneWidget);
  });
}
INTERGRATION
The Full Journey

Integration Tests

Integration tests test complete app workflows on real or simulated devices. Use them for your most critical user paths — login → checkout → confirmation, for example.

📘
// integration_test/checkout_flow_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('Checkout Flow', () {
    testWidgets('complete purchase flow', (tester) async {
      app.main(); // Launch the real app
      await tester.pumpAndSettle();

      // Step 1: Add item to cart
      await tester.tap(find.byKey(const Key('add_to_cart_button')));
      await tester.pumpAndSettle();

      // Step 2: Go to cart
      await tester.tap(find.byKey(const Key('cart_icon')));
      await tester.pumpAndSettle();
      expect(find.text('1 item'), findsOneWidget);

      // Step 3: Checkout
      await tester.tap(find.byKey(const Key('checkout_button')));
      await tester.pumpAndSettle();
      expect(find.text('Order Confirmed'), findsOneWidget);
    });
  });
}
Run on a device: `flutter test integration_test/checkout_flow_test.dart`

Explore project snapshots or discuss custom web solutions.

Code without tests is broken by design.

Jacob Kaplan-Moss, co-creator of Django, 2012

Thank You for Spending Your Valuable Time

I truly appreciate you taking the time to read blog. Your valuable time means a lot to me, and I hope you found the content insightful and engaging!
Front
Back
Right
Left
Top
Bottom
FAQ's

Frequently Asked Questions

Flutter supports unit tests (verify business logic and Dart code), widget tests (validate UI components and interactions), and integration tests (test complete app workflows on real or simulated devices).

No. Chasing 100% often means writing meaningless tests for trivial getters. Aim for 80%+ on business logic. Focus coverage on the paths users actually take, error conditions, and edge cases. Meaningful coverage beats high coverage numbers.

Wrap the plugin in your own abstract class, then mock the wrapper. For example, create `abstract class LocationService { Future<Position> getCurrentPosition(); }` and a `GeolocatorLocationService` that uses the plugin. In tests, inject a `MockLocationService`.

TDD works best for business logic (pure Dart). For UI, a "test-after" approach (writing widget tests right after implementing a widget) works well in practice. The goal is testable code and coverage — the exact order matters less than the habit.

Unit and widget tests run fine on CI without any device — they use a virtual engine. Integration tests require a real device or emulator. Tools like Firebase Test Lab, BrowserStack, and GitHub Actions with Android emulator support make integration tests in CI practical.

Comments are closed