Flutter: Testing — Unit / Widget / Integration Tests

Code without tests is a time bomb — you don't know when it'll explode, only that it will.

📋 Prerequisites: You should already be familiar with

1. What You'll Learn


2. A Real Story of a Production Bug

(1) The Pain Point: Manual Testing Can't Cover Everything

Bob's ShopApp shipped a coupon feature. QA manually tested the "happy path" and it passed, but missed the edge case of "using an expired coupon." After launch, 500 users placed orders with expired coupons, resulting in negative order amounts and database errors. The fix took 4 hours, costing 10 thousand USD. Similar issues happened at least twice a month.

(2) The Automated Testing Solution

Automated tests run on every code commit, covering all edge cases and ensuring new code doesn't break existing functionality.

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

// ⚙️ Dependency: mocktail: ^1.0.0 (add to dev_dependencies)
// CartNotifier / Coupon / Product from ShopApp project, full definition in Lesson 27

class Coupon {
  final String code;
  final DateTime expiresAt;
  const Coupon({required this.code, required this.expiresAt});
}

test('coupon expired should show error', () {
  final cart = CartNotifier();
  cart.addItem(product);
  final result = cart.applyCoupon(Coupon(code: 'SAVE20', expiresAt: DateTime(2020)));
  expect(result, isFalse);
  expect(cart.error, contains('expired'));
});
TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.

(3) The Payoff: Bug Rate Drops 80%

After Bob established a testing system, 200+ tests run automatically on every commit, and the production bug rate dropped from 5/month to 1/month.


3. Testing Pyramid

100%
graph TD
    TP[Testing Pyramid] --> UT[Unit Tests: 70%]
    TP --> WT[Widget Tests: 20%]
    TP --> IT[Integration Tests: 10%]
    UT --> |mocktail| CN3[CartNotifier]
    WT --> |testWidgets| PV3[ProductListView]
    IT --> |integration_test| CK2[Checkout Flow]
TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.
Layer Proportion Speed Dependencies Test Scope
Unit Tests 70% Milliseconds Mock Pure logic (Notifier/Repository)
Widget Tests 20% Seconds Test environment UI rendering + interaction
Integration Tests 10% Minutes Real device/emulator Complete user flows

4. Unit Tests

(1) Project Configuration

YAML
# pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter
  mocktail: ^1.0.0
  build_runner: ^2.4.0

▶ Example

TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.

: CartNotifier unit tests

DART
// test/notifiers/cart_notifier_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

// ⚙️ Dependency: mocktail: ^1.0.0 (add to dev_dependencies)
// ProductRepository / CartNotifier from ShopApp project, full definition in Lesson 27

// Simplified Product (full definition in Lesson 26 domain/entities/product.dart)
class Product {
  final int id;
  final String name;
  final double price;
  final String imageUrl;
  final String category;
  const Product({required this.id, required this.name, required this.price,
    required this.imageUrl, this.category = 'General'});
}

// Simplified CartNotifier (full definition in Lesson 27)
class CartNotifier {
  List<_CartItem> state = [];
  String? error;

  void addItem(Product product) {
    final idx = state.indexWhere((i) => i.product.id == product.id);
    if (idx >= 0) {
      state = [...state]..[idx] = _CartItem(product: state[idx].product, quantity: state[idx].quantity + 1);
    } else {
      state = [...state, _CartItem(product: product, quantity: 1)];
    }
  }

  void updateQuantity(int productId, int quantity) {
    if (quantity <= 0) { removeItem(productId); return; }
    state = [for (final i in state) i.product.id == productId ? _CartItem(product: i.product, quantity: quantity) : i];
  }

  void removeItem(int productId) {
    state = state.where((i) => i.product.id != productId).toList();
  }

  void clear() { state = []; }

  double get total => state.fold(0.0, (sum, i) => sum + i.product.price * i.quantity);
}

class _CartItem {
  final Product product;
  final int quantity;
  _CartItem({required this.product, required this.quantity});
}

class MockProductRepository extends Mock implements ProductRepository {}

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

    setUp(() {
      cart = CartNotifier();
    });

    test('initial state is empty', () {
      expect(cart.state, isEmpty);
    });

    test('addItem adds product to cart', () {
      cart.addItem(testProduct);
      expect(cart.state.length, 1);
      expect(cart.state.first.product, testProduct);
      expect(cart.state.first.quantity, 1);
    });

    test('addItem increments quantity if product exists', () {
      cart.addItem(testProduct);
      cart.addItem(testProduct);
      expect(cart.state.length, 1);
      expect(cart.state.first.quantity, 2);
    });

    test('updateQuantity updates item quantity', () {
      cart.addItem(testProduct);
      cart.updateQuantity(testProduct.id, 5);
      expect(cart.state.first.quantity, 5);
    });

    test('updateQuantity removes item when quantity is 0', () {
      cart.addItem(testProduct);
      cart.updateQuantity(testProduct.id, 0);
      expect(cart.state, isEmpty);
    });

    test('removeItem removes product from cart', () {
      cart.addItem(testProduct);
      cart.removeItem(testProduct.id);
      expect(cart.state, isEmpty);
    });

    test('clear empties the cart', () {
      cart.addItem(testProduct);
      cart.addItem(anotherProduct);
      cart.clear();
      expect(cart.state, isEmpty);
    });

    test('total calculates correct sum', () {
      cart.addItem(testProduct);  // price: 99.99
      cart.addItem(anotherProduct);  // price: 49.99
      expect(cart.total, closeTo(149.98, 0.01));
    });
  });
}

const testProduct = Product(id: 1, name: 'Laptop', price: 99.99, imageUrl: '', category: 'Electronics');
const anotherProduct = Product(id: 2, name: 'Mouse', price: 49.99, imageUrl: '', category: 'Electronics');
TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.

▶ Example

TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.

: Repository unit tests (with Mock)

DART
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:dio/dio.dart';

// ⚙️ Dependencies: mocktail: ^1.0.0, dio: ^5.0.0 (add to dev_dependencies / dependencies)
// ProductRepository / DioClient from ShopApp project, full definition in Lesson 27

class MockDioClient extends Mock implements DioClient {}

void main() {
  group('ProductRepository', () {
    late ProductRepository repo;
    late MockDioClient mockClient;

    setUp(() {
      mockClient = MockDioClient();
      repo = ProductRepository(mockClient);
    });

    test('getProducts returns list of products', () async {
      // Arrange
      when(() => mockClient.get('/products', queryParams: any(named: 'queryParams')))
          .thenAnswer((_) async => Response(
            data: {'items': [{'id': 1, 'name': 'Laptop', 'price': 1299.99, 'category': 'Electronics'}]},
            statusCode: 200, requestOptions: RequestOptions(path: '/products'),
          ));

      // Act
      final products = await repo.getProducts();

      // Assert
      expect(products.length, 1);
      expect(products.first.name, 'Laptop');
      verify(() => mockClient.get('/products', queryParams: any(named: 'queryParams'))).called(1);
    });

    test('getProducts throws on network error', () async {
      when(() => mockClient.get('/products', queryParams: any(named: 'queryParams')))
          .thenThrow(DioException(requestOptions: RequestOptions(path: '/products')));

      expect(() => repo.getProducts(), throwsA(isA<DioException>()));
    });
  });
}
TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.

5. Widget Tests

▶ Example

TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.

: Product card Widget test

DART
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

// Product / ProductCard from ShopApp project, full definition in Lessons 26/27

// Simplified Product
class Product {
  final int id;
  final String name;
  final double price;
  final String imageUrl;
  final String category;
  const Product({required this.id, required this.name, required this.price,
    required this.imageUrl, this.category = 'General'});
}

// Simplified ProductCard
class ProductCard extends StatelessWidget {
  final Product product;
  final VoidCallback onAddToCart;
  const ProductCard({super.key, required this.product, required this.onAddToCart});

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Column(children: [
        Text(product.name),
        Text('\$${product.price.toStringAsFixed(2)}'),
        if (product.price < 50) const Text('SALE'),
        IconButton(icon: const Icon(Icons.add_shopping_cart), onPressed: onAddToCart),
      ]),
    );
  }
}

const testProduct = Product(id: 1, name: 'Laptop', price: 99.99, imageUrl: '', category: 'Electronics');

void main() {
  group('ProductCard', () {
    testWidgets('displays product name and price', (tester) async {
      await tester.pumpWidget(MaterialApp(
        home: Scaffold(body: ProductCard(
          product: testProduct,
          onAddToCart: () {},
        )),
      ));

      expect(find.text('Laptop'), findsOneWidget);
      expect(find.text('\$99.99'), findsOneWidget);
    });

    testWidgets('calls onAddToCart when button tapped', (tester) async {
      var tapped = false;
      await tester.pumpWidget(MaterialApp(
        home: Scaffold(body: ProductCard(
          product: testProduct,
          onAddToCart: () => tapped = true,
        )),
      ));

      await tester.tap(find.byIcon(Icons.add_shopping_cart));
      expect(tapped, isTrue);
    });

    testWidgets('shows SALE badge when price < 50', (tester) async {
      await tester.pumpWidget(MaterialApp(
        home: Scaffold(body: ProductCard(
          product: const Product(id: 1, name: 'Cheap Item', price: 29.99, imageUrl: '', category: 'All'),
          onAddToCart: () {},
        )),
      ));

      expect(find.text('SALE'), findsOneWidget);
    });
  });
}
TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.

▶ Example

TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.

: Cart page Widget test

DART
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// ⚙️ Dependency: flutter_riverpod: ^2.0.0 (add to dependencies)
// CartPage / cartProvider / EmptyCartNotifier / PrefilledCartNotifier from ShopApp project

void main() {
  group('CartPage', () {
    testWidgets('shows empty state when cart is empty', (tester) async {
      await tester.pumpWidget(ProviderScope(
        overrides: [cartProvider.overrideWith(() => EmptyCartNotifier())],
        child: const MaterialApp(home: CartPage()),
      ));

      expect(find.text('Cart is empty'), findsOneWidget);
    });

    testWidgets('shows items and total when cart has items', (tester) async {
      await tester.pumpWidget(ProviderScope(
        overrides: [cartProvider.overrideWith(() => PrefilledCartNotifier())],
        child: const MaterialApp(home: CartPage()),
      ));

      await tester.pumpAndSettle();

      expect(find.text('Laptop'), findsOneWidget);
      expect(find.text('Mouse'), findsOneWidget);
      expect(find.textContaining('\$149.98'), findsOneWidget);
    });

    testWidgets('increments quantity on plus tap', (tester) async {
      await tester.pumpWidget(ProviderScope(
        overrides: [cartProvider.overrideWith(() => PrefilledCartNotifier())],
        child: const MaterialApp(home: CartPage()),
      ));

      await tester.pumpAndSettle();

      // Find first increment button and tap
      await tester.tap(find.byIcon(Icons.add).first);
      await tester.pumpAndSettle();

      // Verify quantity changed
      expect(find.text('2'), findsOneWidget);
    });
  });
}
TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.

6. Integration Tests

▶ Example

TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.

: Checkout flow end-to-end test

DART
// integration_test/checkout_test.dart
import 'package:flutter/material.dart';
import 'package:integration_test/integration_test.dart';

// ⚙️ Dependency: integration_test SDK (add to dev_dependencies)
// app (ShopApp main entry) from project lib/main.dart

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('Checkout Flow', () {
    testWidgets('complete purchase end-to-end', (tester) async {
      app.main();
      await tester.pumpAndSettle();

      // 1. Browse products
      expect(find.text('ShopApp'), findsOneWidget);

      // 2. Add product to cart
      await tester.tap(find.byIcon(Icons.add_shopping_cart).first);
      await tester.pumpAndSettle();

      // 3. Navigate to cart
      await tester.tap(find.byIcon(Icons.shopping_cart));
      await tester.pumpAndSettle();

      // 4. Verify cart has item
      expect(find.textContaining('\$'), findsWidgets);

      // 5. Tap checkout
      await tester.tap(find.text('Checkout'));
      await tester.pumpAndSettle();

      // 6. Fill checkout form
      await tester.enterText(find.byType(TextFormField).at(0), 'Alice Smith');
      await tester.enterText(find.byType(TextFormField).at(1), '123 Main St');
      await tester.pumpAndSettle();

      // 7. Submit order
      await tester.tap(find.textContaining('Pay'));
      await tester.pumpAndSettle();

      // 8. Verify order confirmation
      expect(find.text('Order Confirmed'), findsOneWidget);
    });
  });
}
TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly by platform.

7. Testing Best Practices

Practice Description
AAA Pattern Arrange → Act → Assert
Independent tests setUp/setUpEach for initialization, no cross-test dependencies
Mock external dependencies Replace network/database/platform APIs with Mocks
Descriptive test names test('addItem increments quantity when product exists')
Use pumpAndSettle for Widget tests Wait for all animations and async operations to complete

❓ FAQ

Q How to distinguish unit tests from Widget tests?
A Unit tests don't render UI — they only test logic; Widget tests render Widgets in a test environment and verify UI and interaction.
Q What's the difference between mocktail and mockito?
A mocktail is mockito's Dart-native version — it doesn't need code generation and has a simpler API. mocktail is recommended.
Q What's the difference between pump and pumpAndSettle?
A pump executes one frame; pumpAndSettle executes all frames until there are no new ones (animations complete/async finished).
Q How to run integration tests in CI?
A Use flutter test integration_test/ on an Android emulator. iOS requires a macOS runner.
Q How to maintain Golden tests?
A First run generates golden files; subsequent runs compare against them. Update with --update-goldens when UI changes. Watch out for cross-platform rendering differences.
Q How to override Riverpod Providers in tests?
A Use ProviderScope(overrides: [myProvider.overrideWith(...)]) to replace the real Provider with a test version.

📖 Summary


📝 Exercises

  1. Basic (Difficulty ⭐): Write 5 unit tests for CartNotifier: initial empty, add, add duplicate, remove, clear.
  2. Intermediate (Difficulty ⭐⭐): Write Widget tests for ProductCard: displays product name/price, tapping add-to-cart triggers callback, shows SALE badge.
  3. Challenge (Difficulty ⭐⭐⭐): Implement a complete test suite: CartNotifier unit tests (8+ cases) + CartPage Widget tests (5+ cases) + checkout integration test (complete user flow).

← Previous Lesson | Next Lesson →

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏