← 返回AI教程
🌐 其他

How to Test AI Features in Flutter [Full Handbook]

来源:freeCodeCamp · 发布于 2026-08-08 00:05:31
How to Test AI
You've spent two weeks building an AI assistant. The streaming chat looks beautiful, the system prompt is tight, and safety filters are configured. You demoed it to the team, and everyone was impresse

You've spent two weeks building an AI assistant. The streaming chat looks beautiful, the system prompt is tight, and safety filters are configured.

You demoed it to the team, and everyone was impressed. You submitted to the App Store, and it went live.

Three days after launch, a user reports that tapping the send button twice in quick succession shows two loading spinners that never resolve. Another user finds that if they close the app mid-stream and reopen it, the chat screen crashes.

Someone on your team changes the error message string in your AIRepository, and the widget test suite still passes because the tests were asserting on the wrong thing. A product manager asks whether the new feature breaks if the Gemini API is unavailable, and nobody knows because it was never tested.

The analytics dashboard shows that four percent of sessions end with a blank AI response and no visible error, and you have no idea how long this has been happening.

None of these were bugs in the AI model. They were bugs in your Flutter code. And they were the same class of bugs you would catch immediately in any other feature, except you never wrote the tests.

The testing gap in AI feature development is systematic and well understood. Developers focus on the happy path because the happy path is what the demo needed. The AI integration feels magical and complex, so testing feels like it would require mocking magic and complex things. And the model output is non-deterministic, so the instinct is to assume testing is futile.

All three of those assumptions are wrong, and this handbook dismantles all three of them in detail.

Testing AI features in Flutter isn't about testing the model. Gemini is Google's responsibility. What you're testing is your own code: the repository layer that wraps the model, the Bloc that drives state transitions, the widgets that render responses and loading states and errors, the error handlers that catch safety blocks and quota limits, the rate limiter that throttles requests, and the system prompt logic that gates what the model will and will not respond to.

All of that is your code, and all of it is testable with standard Flutter testing tools.

This handbook covers every layer of that testing strategy:

  • Unit tests for the repository layer using mocks

  • Widget tests for the chat screen using controlled fake responses

  • Streaming tests that simulate chunk-by-chunk delivery

  • Golden tests that lock down the visual appearance of AI-rendered markdown content

  • Adversarial input tests that verify your system prompt holds under attack

  • Error state tests that verify every failure mode shows a human-readable message

  • Integration tests that use the Firebase Local Emulator to exercise the real stack without hitting production APIs

By the end, you'll have a complete testing strategy for AI features and a reusable set of test utilities that you can carry into every AI project you build.

Table of Contents

Prerequisites

This handbook assumes you're building on an existing foundation. You don't need to be a testing expert, but you do need the following:

1. Familiarity with the firebase_ai package

This guide tests code that uses the firebase_ai package to call Gemini through Firebase AI Logic. If you haven't set this up, the handbook on AI in production (How to Build Production-Ready AI Features with Flutter) covers the full setup. The test strategy here is directly complementary to that handbook's architecture.

2. Flutter testing basics

You should know what flutter test does, what a testWidgets block looks like, and what expect(actual, matcher) means. You don't need advanced testing knowledge because this guide builds the concepts from the ground up, but having written at least one widget test before will help.

3. Bloc for state management

The examples use flutter_bloc as the state management layer, because that is the architecture the production AI handbook established. If you use Riverpod or Provider, the same concepts apply: you replace the Bloc with your state management primitive, and the mock injection patterns remain identical.

4. mocktail for mocking

This guide uses mocktail rather than mockito because mocktail works without code generation, which makes it faster to set up and easier to maintain. The concepts are identical to mockito if your team already uses it.

5. Tools and packages

Add the following to your pubspec.yaml under dev_dependencies:

dev_dependencies:
  flutter_test:
    sdk: flutter
  integration_test:
    sdk: flutter
  mocktail: ^1.0.4
  bloc_test: ^9.1.0
  golden_toolkit: ^0.15.0
  fake_async: ^1.3.1

flutter_test is the standard Flutter testing framework included with the SDK. It provides testWidgets, WidgetTester, expect, and all the core testing primitives.

integration_test is the SDK's integration test runner, required for tests that run on a real device or emulator and exercise the app end to end.

mocktail generates mock objects at runtime without code generation, letting you write fakes for the AI client and repository without running build_runner.

bloc_test extends the standard test framework with Bloc-specific matchers like blocTest and emitsInOrder, making it dramatically easier to assert on sequences of state transitions.

golden_toolkit extends golden file testing with device-size simulation and font loading utilities, essential for making golden tests reliable across different machines.

And fake_async lets you control time in tests, advancing timers and delays without actually waiting, which is essential for testing debounced inputs, polling behavior, and stream timeouts.

Why AI Features Need a Different Testing Mindset

The Temptation to Skip Testing

There's a specific thought pattern that causes developers to skip tests on AI features, and it's worth naming it directly before dismantling it.

The thought goes: "The AI response is non-deterministic. Every time I call Gemini, I get a slightly different answer. So any test I write that checks the output would be fragile and brittle. And if I mock the AI, I'm not really testing anything real. So testing AI features is kind of pointless."

Every part of that reasoning is flawed, but it's coherent enough to feel true, which is why it persists across teams.

The non-determinism argument is a category error. You're not testing Gemini. You're testing what your Flutter app does with whatever Gemini returns.

Your app's behavior in response to a response (any response) is completely deterministic: it should render the text, update the state, handle the stream, and dismiss the loading indicator. None of that depends on what the text says.

A mock that returns "Here is your answer" exercises your rendering code just as thoroughly as a real Gemini call that returns "Based on your question, I would suggest the following approach."

The "mocking is not testing anything real" argument conflates two different things: the model's correctness (Gemini's job) and your code's correctness (your job). When you mock the AI client, you test your code. That's precisely the point. Your code is what you're responsible for. The model has its own evaluation infrastructure at Google.

What You Are Actually Testing

Diagram showing what's in scope and out of scope for testing AI code

The image above shows a two-section infographic explaining the boundary between what developers should and should not test in a Flutter AI application.

The top blue section, labeled "Gemini API (Google's responsibility, not yours)," lists items that are outside the application's testing scope, including model quality, factual accuracy, safety filter behavior, token limits, and response format. It notes that these aspects are owned and tested by Google.

Below it, a larger green section labeled "Your Code (Your responsibility, fully testable)" is divided into four categories. The AI Repository Layer covers mapping Gemini responses to domain models, handling finish reasons, converting Firebase exceptions into domain exceptions, logging token usage, and validating prompts.

The State Management (Bloc) section focuses on loading, streaming, error handling, and rate limiting. The Widget Layer includes loading indicators, AI attribution labels, flag buttons, retry banners, and disabling the send button during streaming.

The Cross-Cutting Concerns section covers prompt resilience against adversarial inputs, offline behavior, duplicate request prevention, and stream cancellation.

The diagram emphasizes that only application code should be tested, while the Gemini model itself should be treated as an external dependency.

Every box under the "Your Responsibility" category is fully unit-testable, widget-testable, or integration-testable with deterministic mock inputs. None of it requires a real Gemini API call to verify.

The Problem: Why Standard Testing Falls Short

The Async and Streaming Challenge

Most Flutter feature tests deal with a simple async pattern: press button, wait for future, assert on result.

AI features introduce a different pattern that most testing tutorials don't cover: streaming. When Gemini responds, it sends chunks of text one at a time over a stream. Your UI needs to accumulate those chunks and re-render on every arrival. Testing this properly requires simulating a stream that yields multiple values over time, something Future-based test patterns simply can't express.

The State Machine Complexity

A typical network feature has three states: loading, loaded, and error. An AI chat feature has at least six: idle, streaming-loading (establishing connection), streaming-in-progress (chunks arriving), streaming-complete, error (various sub-types), and content-blocked.

Each transition needs its own test, and the transitions can happen from different starting states depending on user behavior. A standard testWidgets block that just pumps the widget and checks one state misses most of this complexity.

The Fake Data Problem

The challenge with faking AI output is that the structure of the fake must match exactly what the real Gemini client returns. If your fake returns a plain string but your real code expects a GenerateContentResponse with a candidates list and a finishReason, your test will pass while your production code fails. Getting the fake structure right requires understanding the client's response shape deeply enough to replicate it in tests.

The System Prompt Testing Gap

System prompts are business logic. They define what your AI feature will and will not do. But almost no Flutter team tests them.

The system prompt sits in a string constant somewhere, gets sent to Gemini with every request, and the team assumes it works based on manual testing during development. When the prompt is quietly updated (or accidentally broken), nothing catches it. Testing system prompt behavior, even at a basic level, is both possible and important.

Your Testing Architecture: The Three Layers

Before writing a single test, establish the mental model for how your tests are organized. There are three layers, each with a different scope and a different tool.

Diagram showing an inverted pyramid structure with unit tests at the top (fast and cheap), widget tests in the middle (require the Flutter framework, slower), and integration tests at the bottom (fewest number of tests, slower).

This diagram shows a vertically stacked three-layer testing architecture illustrating the recommended testing strategy for Flutter AI applications.

The top layer, Unit Tests, represents the fastest and most numerous tests. It covers repository methods, Bloc state transitions, rate limiting, prompt sanitization, and token logging. The recommended tools are dart test, bloc_test, and mocktail, with full mocking of the AI client.

A downward arrow connects to the Widget Tests layer, which validates the Flutter user interface in isolation. This layer verifies chat screen rendering, streaming indicators, error banners, disabled send buttons during streaming, and golden tests. Recommended tools include flutter test, testWidgets, and golden_toolkit, using fake Blocs or repositories.

Another downward arrow connects to the Integration Tests layer at the bottom. This layer tests complete application behavior using the Firebase Local Emulator Suite, including full application flow, real data streams, lifecycle events, and offline network behavior. It uses the integration_test package and Firebase emulators while avoiding real Gemini API calls.

The diagram communicates that testing moves from fast, isolated tests at the top to slower, more realistic end-to-end tests at the bottom.

The pyramid shape is intentional and important. You want many unit tests because they're fast to run and cheap to write. You want fewer widget tests because they require the Flutter framework and are slower. You want the fewest integration tests because they require a running emulator and take the longest.

The vast majority of your AI feature bugs will be caught by unit and widget tests. Integration tests catch the remaining class of bugs that only appear in the full system.

Setting Up Your Test Environment

Directory Structure

Before writing tests, establish a directory structure that mirrors your source tree:

test/
  unit/
    ai/
      ai_repository_test.dart
      rate_limiter_test.dart
      prompt_sanitizer_test.dart
    bloc/
      chat_bloc_test.dart
  widget/
    screens/
      chat_screen_test.dart
    widgets/
      ai_message_bubble_test.dart
      streaming_indicator_test.dart
  golden/
    chat_screen/
      idle_state.png
      streaming_state.png
      error_state.png
  helpers/
    fakes.dart          -- Shared fake objects and stream builders
    matchers.dart       -- Custom expect matchers for AI-specific types
    test_helpers.dart   -- Shared pump helpers and widget wrappers

integration_test/
  ai_chat_flow_test.dart
  offline_behavior_test.dart

test/helpers/fakes.dart is the most important file in your test suite. It contains the reusable mock and fake objects that every other test file imports. Setting this up correctly once saves enormous time across the entire test suite.

The Core Test Helpers File

// test/helpers/fakes.dart

import 'package:firebase_ai/firebase_ai.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mocktail/mocktail.dart';
import 'package:your_app/ai/ai_repository.dart';
import 'package:your_app/features/ai_chat/bloc/chat_bloc.dart';

// Mock classes: mocktail generates these at runtime with no code generation.
// The class name convention is Mock + ClassName, which is standard and
// makes mocks immediately recognizable across the test suite.

class MockAIRepository extends Mock implements AIRepository {}
class MockChatBloc extends Mock implements ChatBloc {}
class MockGenerativeModel extends Mock implements GenerativeModel {}
class MockChatSession extends Mock implements ChatSession {}

// FakeGenerateContentResponse builds a synthetic GenerateContentResponse
// that looks exactly like what the real Gemini client returns.
// Every test that needs to simulate a successful AI response uses this.
GenerateContentResponse fakeSuccessResponse(String text) {
  // GenerateContentResponse has a complex internal structure.
  // We reconstruct the minimum required shape that our repository code
  // actually accesses: a candidates list with one item, that item having
  // a content with text parts, and a finishReason of FinishReason.stop.
  return GenerateContentResponse(
    [
      Candidate(
        Content.text(text),
        [SafetyRating(HarmCategory.harassment, HarmProbability.negligible)],
        null,
        FinishReason.stop,
      ),
    ],
    null, // promptFeedback is null for a clean response
    UsageMetadata(promptTokenCount: 50, candidatesTokenCount: 100, totalTokenCount: 150),
  );
}

// fakeBlockedResponse simulates a safety-blocked response.
// The finishReason is FinishReason.safety and there is no text.
// This is what Gemini returns when a prompt or response triggers a safety filter.
GenerateContentResponse fakeBlockedResponse() {
  return GenerateContentResponse(
    [
      Candidate(
        Content.text(''),
        [SafetyRating(HarmCategory.harassment, HarmProbability.high)],
        null,
        FinishReason.safety,
      ),
    ],
    null,
    UsageMetadata(promptTokenCount: 30, candidatesTokenCount: 0, totalTokenCount: 30),
  );
}

// fakeStreamedResponse builds a Stream<GenerateContentResponse> that
// emits the text in chunks, one word at a time.
// This simulates how Gemini's streaming API actually behaves:
// chunks arrive in sequence, each containing a partial text fragment.
Stream<GenerateContentResponse> fakeStreamedResponse(String fullText) async* {
  final words = fullText.split(' ');
  for (final word in words) {
    // Each yielded response contains one word (with a trailing space).
    // In real Gemini responses, the chunk sizes are variable,
    // but simulating word-by-word is sufficient to test accumulation logic.
    yield fakeSuccessResponse('$word ');
    // A small delay makes the stream behave more like a real one.
    // Without the delay, all chunks arrive in the same microtask,
    // which can miss timing-sensitive bugs.
    await Future.delayed(const Duration(milliseconds: 10));
  }
}

// fakeTruncatedStreamedResponse simulates a response that gets cut off
// by the maxTokens limit mid-generation. The last chunk has
// finishReason.maxTokens instead of finishReason.stop.
Stream<GenerateContentResponse> fakeTruncatedStreamedResponse(String partialText) async* {
  yield fakeSuccessResponse(partialText);
  yield GenerateContentResponse(
    [
      Candidate(
        Content.text(''),
        [],
        null,
        FinishReason.maxTokens,
      ),
    ],
    null,
    UsageMetadata(promptTokenCount: 50, candidatesTokenCount: 200, totalTokenCount: 250),
  );
}

MockAIRepository extends Mock implements AIRepository creates a mock that implements every method of AIRepository but does nothing by default. You then use when(...).thenAnswer(...) in individual tests to configure what each method should return for that test.

fakeSuccessResponse(String text) builds a real GenerateContentResponse object with the exact internal structure that your repository code navigates. Returning a plain String from a mock would be wrong because your repository code calls response.candidates.first.finishReason and candidate.text, which don't exist on a string. The fake must match the shape of the real object.

fakeStreamedResponse(String fullText) is an async* generator function, using Dart's generator syntax to yield values over time. Each yield sends one chunk into the stream.

The await Future.delayed(...) between yields is important for realistic timing. Without it, the entire stream completes in a single event loop tick, which doesn't expose timing-related bugs in your accumulation logic.

Mocking the AI Client: The Foundation of Everything

Why You Can't Use the Real Client in Tests

The real firebase_ai GenerativeModel makes HTTP calls to Google's servers. Tests that depend on real network calls are slow (seconds per test rather than milliseconds), flaky (they fail when the network is down, when the API key is invalid, or when the quota is exceeded), and expensive (every test run costs money). You never want real API calls in unit or widget tests.

Creating a Testable Architecture with Dependency Injection

The prerequisite for testability is dependency injection. If your ChatBloc creates its own AIRepository internally, you can't replace it with a mock in tests. The repository must be injected from outside:

// lib/features/ai_chat/bloc/chat_bloc.dart

class ChatBloc extends Bloc<ChatEvent, ChatState> {
  final AIRepository _repository;
  final AIRateLimiter _rateLimiter;

  // The repository and rate limiter are injected through the constructor.
  // In production code, the DI setup provides real implementations.
  // In tests, the test provides mocks.
  // ChatBloc never knows which it is getting. That is the point.
  ChatBloc({
    required AIRepository repository,
    required AIRateLimiter rateLimiter,
  })  : _repository = repository,
        _rateLimiter = rateLimiter,
        super(const ChatInitial()) {
    on<SendMessageEvent>(_onSendMessage);
    on<FlagMessageEvent>(_onFlagMessage);
  }

  Future<void> _onSendMessage(
    SendMessageEvent event,
    Emitter<ChatState> emit,
  ) async {
    if (!_rateLimiter.canMakeRequest(event.userId)) {
      emit(ChatError(
        messages: state.messages,
        errorMessage: 'Daily limit reached. Try again tomorrow.',
      ));
      return;
    }

    emit(ChatStreaming(messages: state.messages, streamingContent: ''));

    _rateLimiter.recordRequest(event.userId);

    try {
      await emit.forEach(
        _repository.sendMessage(event.message),
        onData: (String accumulated) => ChatStreaming(
          messages: state.messages,
          streamingContent: accumulated,
        ),
        onError: (e, _) => ChatError(
          messages: state.messages,
          errorMessage: e is AIException ? e.userMessage : 'Something went wrong.',
        ),
      );
    } on AIException catch (e) {
      emit(ChatError(messages: state.messages, errorMessage: e.userMessage));
    }
  }
}

required AIRepository repository and required AIRateLimiter rateLimiter declare that these dependencies come from the caller. When ChatBloc is created in main.dart, the real implementations are passed. When ChatBloc is created in a test, a mock is passed.

The Bloc itself has no if (isTest) branching and no awareness of which path it is on. This is the core principle of testable design: the thing being tested should be ignorant of the test.

Configuring Mocks with mocktail

// Inside any test file that needs a mocked repository

void main() {
  late MockAIRepository mockRepository;
  late MockAIRateLimiter mockRateLimiter;

  setUp(() {
    mockRepository = MockAIRepository();
    mockRateLimiter = MockAIRateLimiter();

    // Configure the rate limiter to always allow requests by default.
    // Individual tests that want to test the "rate limited" path will
    // override this with a when() that returns false.
    when(() => mockRateLimiter.canMakeRequest(any())).thenReturn(true);
    when(() => mockRateLimiter.recordRequest(any())).thenReturn(null);
  });
}

setUp(() { ... }) runs before every test in the group. Creating fresh mock instances in setUp ensures that state from one test can't leak into another.

when(() => mockRateLimiter.canMakeRequest(any())).thenReturn(true) uses mocktail's any() matcher to match any argument passed to canMakeRequest. This sets a default return value. Without this line, calling canMakeRequest on the mock would throw a MissingStubError because mocktail doesn't return default values unless you configure them explicitly.

thenReturn(null) for recordRequest is correct because recordRequest is a void method and needs an explicit stub to not throw.

Unit Testing the AI Repository Layer

The AIRepository is the most important class to test thoroughly because it's the translation layer between the raw Gemini API and your domain types. Every error mapping, safety check, and token log happens here. If this class works correctly, the Bloc above it can trust what it receives.

Testing Successful Text Generation

// test/unit/ai/ai_repository_test.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:firebase_ai/firebase_ai.dart';
import 'package:your_app/ai/ai_repository.dart';
import 'package:your_app/ai/ai_exceptions.dart';
import '../../helpers/fakes.dart';

void main() {
  late MockGenerativeModel mockModel;
  late AIRepository repository;

  setUp(() {
    mockModel = MockGenerativeModel();
    repository = AIRepository(model: mockModel);
  });

  group('generateText', () {
    test('returns text content when response is successful', () async {
      // Arrange: configure the mock to return a successful response
      // when generateContent is called with any list of Content objects.
      when(() => mockModel.generateContent(any()))
          .thenAnswer((_) async => fakeSuccessResponse('Hello, this is the AI response.'));

      // Act: call the method under test
      final result = await repository.generateText('Tell me something.');

      // Assert: the result is the text from the fake response
      expect(result, equals('Hello, this is the AI response.'));

      // Verify: generateContent was called exactly once
      verify(() => mockModel.generateContent(any())).called(1);
    });

    test('throws AIValidationException for empty prompt', () async {
      // No mock configuration needed here because the repository
      // should validate the input BEFORE calling the model.
      // If generateContent were called, that would be a bug.

      expect(
        () => repository.generateText(''),
        throwsA(isA<AIValidationException>()),
      );

      // Verify the model was NEVER called (validation failed first)
      verifyNever(() => mockModel.generateContent(any()));
    });

    test('throws AIValidationException for prompt exceeding max length', () async {
      final tooLongPrompt = 'a' * 4001; // one character over the 4000 limit

      expect(
        () => repository.generateText(tooLongPrompt),
        throwsA(isA<AIValidationException>()),
      );

      verifyNever(() => mockModel.generateContent(any()));
    });

    test('throws AIContentBlockedException when response is safety-blocked', () async {
      when(() => mockModel.generateContent(any()))
          .thenAnswer((_) async => fakeBlockedResponse());

      expect(
        () => repository.generateText('What is the best way to hurt someone?'),
        throwsA(isA<AIContentBlockedException>()),
      );
    });

    test('throws AIQuotaException when Firebase returns quota-exceeded', () async {
      // Simulate the specific FirebaseException that indicates quota exhaustion
      when(() => mockModel.generateContent(any())).thenThrow(
        FirebaseException(
          plugin: 'firebase_ai',
          code: 'quota-exceeded',
          message: 'Quota exceeded for project.',
        ),
      );

      expect(
        () => repository.generateText('Any prompt'),
        throwsA(isA<AIQuotaException>()),
      );
    });

    test('throws AINetworkException for unknown Firebase errors', () async {
      when(() => mockModel.generateContent(any())).thenThrow(
        FirebaseException(
          plugin: 'firebase_ai',
          code: 'unavailable',
          message: 'Service temporarily unavailable.',
        ),
      );

      expect(
        () => repository.generateText('Any prompt'),
        throwsA(isA<AINetworkException>()),
      );
    });

    test('returns partial text with truncation note when maxTokens reached', () async {
      final truncatedResponse = GenerateContentResponse(
        [
          Candidate(
            Content.text('The answer begins here but'),
            [],
            null,
            FinishReason.maxTokens,
          ),
        ],
        null,
        UsageMetadata(promptTokenCount: 50, candidatesTokenCount: 200, totalTokenCount: 250),
      );

      when(() => mockModel.generateContent(any()))
          .thenAnswer((_) async => truncatedResponse);

      final result = await repository.generateText('Long question');

      // The repository should return the partial text with a note
      expect(result, contains('The answer begins here but'));
      expect(result, contains('[Note: Response was truncated'));
    });
  });
}

when(() => mockModel.generateContent(any())).thenAnswer((_) async => fakeSuccessResponse(...)) is the mocktail stub pattern. The any() matcher matches any argument, so this stub fires regardless of what list of Content objects is passed to generateContent.

thenAnswer((_) async => ...) returns an async value because generateContent returns a Future. Using thenReturn for async methods would cause subtle issues, so thenAnswer is always the right choice for futures and streams.

throwsA(isA<AIValidationException>()) is a matcher that passes only when the callable throws an AIValidationException or any subtype of it. This verifies that your input validation throws the right exception type rather than the wrong one or none at all.

verifyNever(() => mockModel.generateContent(any())) asserts that generateContent was never called. This is critical for the validation tests: if the repository calls the model even when the input is invalid, that's a real bug (wasted quota, potential security issue) and the test should catch it.

The maxTokens test asserts on contains(...) rather than equals(...) because the exact truncation message is an implementation detail. Checking that the original text and the note are both present is more resilient to message wording changes.

Testing Token Usage Logging

Token logging is a production concern you should test, because if the logging code breaks silently, you lose your cost monitoring:

test('logs token usage after successful generation', () async {
  final List<Map<String, int>> loggedUsage = [];

  // Override the repository's logging method using a spy approach.
  // We create a repository subclass that captures what would be logged.
  final spyRepository = SpyAIRepository(
    model: mockModel,
    onTokensLogged: (usage) => loggedUsage.add(usage),
  );

  when(() => mockModel.generateContent(any()))
      .thenAnswer((_) async => fakeSuccessResponse('Answer'));

  await spyRepository.generateText('Question');

  expect(loggedUsage, hasLength(1));
  expect(loggedUsage.first['promptTokens'], equals(50));
  expect(loggedUsage.first['responseTokens'], equals(100));
});

SpyAIRepository is a test subclass of AIRepository that accepts a callback to intercept what would normally be logged to analytics. This pattern (sometimes called a test spy) lets you verify that a side effect occurred without modifying the production class and without relying on a logging framework that may be difficult to mock.

The loggedUsage.add(usage) callback captures the exact values that were passed to the logger, which you then assert on. This test fails if the token logging code is accidentally removed or if it logs the wrong fields, both of which matter for cost monitoring.

Widget Testing AI-Powered Screens

Widget tests run the Flutter framework but don't make real network calls. They're the right tool for testing that your chat screen shows the correct widgets in each state, that user interactions trigger the right events, and that the layout is correct.

Setting Up the Widget Test Helper

// test/helpers/test_helpers.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/features/ai_chat/bloc/chat_bloc.dart';
import 'package:your_app/features/ai_chat/chat_screen.dart';

// pumpChatScreen wraps the ChatScreen with the required providers
// and pumps it into the test widget tree.
// Every widget test for the chat screen calls this instead of
// building the wrapper manually each time.
Future<void> pumpChatScreen(
  WidgetTester tester, {
  required ChatBloc bloc,
}) async {
  await tester.pumpWidget(
    MaterialApp(
      // MaterialApp is required because the chat screen uses
      // Scaffold, which requires a Material ancestor.
      home: BlocProvider<ChatBloc>.value(
        // .value constructor provides an existing Bloc instance
        // without creating a new one. This lets the test retain
        // a reference to the bloc so it can emit states later.
        value: bloc,
        child: const AIChatScreen(),
      ),
    ),
  );
}

BlocProvider<ChatBloc>.value(value: bloc, ...) injects the bloc into the widget tree without creating or closing it. If you use the regular BlocProvider(create: (_) => ChatBloc(...), ...) in tests, the provider creates and owns the bloc, making it impossible for the test to control what states the bloc emits. The .value constructor gives the test full control.

pumpChatScreen is a helper function rather than a widget because it keeps each test's setup code minimal. Tests that need the chat screen call one line instead of building the full wrapper every time.

Testing the Idle State

// test/widget/screens/chat_screen_test.dart

import 'package:bloc_test/bloc_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:your_app/features/ai_chat/bloc/chat_bloc.dart';
import '../../helpers/fakes.dart';
import '../../helpers/test_helpers.dart';

void main() {
  late MockChatBloc mockBloc;

  setUp(() {
    mockBloc = MockChatBloc();
    // Every Bloc mock needs to have its stream and state configured.
    // The stream property is what BlocBuilder listens to.
    // state is what BlocBuilder reads for the initial render.
    when(() => mockBloc.stream).thenAnswer((_) => const Stream.empty());
    when(() => mockBloc.state).thenReturn(const ChatInitial());
  });

  group('AIChatScreen idle state', () {
    testWidgets('shows empty state view when no messages', (tester) async {
      await pumpChatScreen(tester, bloc: mockBloc);

      // The empty state should show the AI assistant name and a hint
      expect(find.text('Kopa AI Assistant'), findsOneWidget);
      expect(find.text('Ask me about your budget...'), findsOneWidget);

      // The send button should be present but the input should be empty
      expect(find.byType(TextField), findsOneWidget);
      expect(find.byIcon(Icons.send_rounded), findsOneWidget);
    });

    testWidgets('send button is disabled when text field is empty', (tester) async {
      await pumpChatScreen(tester, bloc: mockBloc);

      // Find the FilledButton that wraps the send icon
      final sendButton = tester.widget<FilledButton>(
        find.ancestor(
          of: find.byIcon(Icons.send_rounded),
          matching: find.byType(FilledButton),
        ),
      );

      // A null onPressed means the button is disabled
      expect(sendButton.onPressed, isNull);
    });

    testWidgets('typing in field enables the send button', (tester) async {
      await pumpChatScreen(tester, bloc: mockBloc);

      await tester.enterText(find.byType(TextField), 'What is my balance?');
      await tester.pump(); // rebuild after state change

      final sendButton = tester.widget<FilledButton>(
        find.ancestor(
          of: find.byIcon(Icons.send_rounded),
          matching: find.byType(FilledButton),
        ),
      );

      expect(sendButton.onPressed, isNotNull);
    });

    testWidgets('tapping send dispatches SendMessageEvent to bloc', (tester) async {
      await pumpChatScreen(tester, bloc: mockBloc);

      await tester.enterText(find.byType(TextField), 'Tell me about my spending');
      await tester.pump();

      await tester.tap(find.byIcon(Icons.send_rounded));
      await tester.pump();

      // Verify the bloc received exactly one SendMessageEvent
      // with the correct message text
      verify(
        () => mockBloc.add(
          SendMessageEvent(message: 'Tell me about my spending'),
        ),
      ).called(1);
    });
  });
}

when(() => mockBloc.stream).thenAnswer((_) => const Stream.empty()) is required because BlocBuilder subscribes to the bloc's stream immediately. Without this stub, the mock would throw because stream isn't configured. const Stream.empty() returns a stream that completes immediately with no events, which means the BlocBuilder renders once with the initial state and then stops updating.

when(() => mockBloc.state).thenReturn(const ChatInitial()) configures the initial state that BlocBuilder reads on first render. Together, state and stream are the two things every Bloc mock needs configured.

find.ancestor(of: find.byIcon(Icons.send_rounded), matching: find.byType(FilledButton)) navigates the widget tree upward from the icon to find its ancestor FilledButton. This is necessary because the icon and the button are two separate widgets in the tree, and you need the button to check onPressed.

expect(sendButton.onPressed, isNull) asserts that the button is disabled. Flutter buttons are disabled when onPressed is null. This is more precise than checking for a disabled visual style, which could pass even if the logic is wrong.

verify(() => mockBloc.add(SendMessageEvent(...))).called(1) confirms that exactly one event was dispatched with the exact expected content. Checking the event was dispatched (not just that the UI did something) is the right assertion for this test, because it's the event that drives all the downstream behavior.

Testing the Streaming State

group('AIChatScreen streaming state', () {
  testWidgets('shows streaming indicator while AI is responding', (tester) async {
    // Configure the bloc to be in a streaming state
    when(() => mockBloc.state).thenReturn(
      ChatStreaming(
        messages: const [
          ChatMessage(
            id: 'msg1',
            isAI: false,
            content: 'What is my balance?',
            timestamp: null,
          ),
        ],
        streamingContent: 'Your balance is', // partial response in progress
      ),
    );

    await pumpChatScreen(tester, bloc: mockBloc);

    // The partial streaming content should be visible
    expect(find.text('Your balance is'), findsOneWidget);

    // A progress indicator should be showing alongside the streaming bubble
    expect(find.byType(CircularProgressIndicator), findsOneWidget);

    // The send button should be disabled during streaming
    final sendButton = tester.widget<FilledButton>(
      find.ancestor(
        of: find.byIcon(Icons.send_rounded),
        matching: find.byType(FilledButton),
      ),
    );
    expect(sendButton.onPressed, isNull);
  });

  testWidgets('accumulates text across streaming updates', (tester) async {
    // Start with an empty streaming state
    final streamController = StreamController<ChatState>();

    when(() => mockBloc.stream).thenAnswer((_) => streamController.stream);
    when(() => mockBloc.state).thenReturn(
      ChatStreaming(messages: const [], streamingContent: ''),
    );

    await pumpChatScreen(tester, bloc: mockBloc);

    // Emit a first chunk
    streamController.add(
      ChatStreaming(messages: const [], streamingContent: 'Hello'),
    );
    await tester.pump();

    expect(find.text('Hello'), findsOneWidget);

    // Emit an accumulated second chunk (the bloc accumulates, not just appends)
    streamController.add(
      ChatStreaming(messages: const [], streamingContent: 'Hello world'),
    );
    await tester.pump();

    // The full accumulated text should be displayed
    expect(find.text('Hello world'), findsOneWidget);
    // The partial first chunk should no longer appear by itself
    expect(find.text('Hello'), findsNothing);

    await streamController.close();
  });
});

StreamController<ChatState> is the key tool for simulating a live bloc state stream in widget tests. You create the controller, stub the bloc's stream property to use the controller's stream, and then call streamController.add(...) to push new states during the test.

await tester.pump() after each add call tells the test framework to process the new frame and rebuild affected widgets. Without pump(), the widget doesn't visually update and the find assertions will see the previous render.

The test for accumulated text verifies a subtle but critical behavior: the bloc emits the full accumulated string, not just the latest chunk, and the widget replaces the entire streaming content on each update rather than appending. find.text('Hello') finding nothing after the second update confirms the widget correctly replaced the partial text.

Testing Streaming Responses and Streaming UI

Testing the Stream Accumulation Logic in the Bloc

The most important streaming behavior to test is in the Bloc: that it correctly accumulates chunks from the repository's stream into a growing string that the UI can display progressively. This is a Bloc unit test, not a widget test.

// test/unit/bloc/chat_bloc_test.dart

import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:your_app/features/ai_chat/bloc/chat_bloc.dart';
import 'package:your_app/ai/ai_repository.dart';
import 'package:your_app/ai/ai_exceptions.dart';
import '../../helpers/fakes.dart';

void main() {
  late MockAIRepository mockRepository;
  late MockAIRateLimiter mockRateLimiter;

  setUp(() {
    mockRepository = MockAIRepository();
    mockRateLimiter = MockAIRateLimiter();
    when(() => mockRateLimiter.canMakeRequest(any())).thenReturn(true);
    when(() => mockRateLimiter.recordRequest(any())).thenReturn(null);
  });

  ChatBloc buildBloc() => ChatBloc(
    repository: mockRepository,
    rateLimiter: mockRateLimiter,
  );

  group('SendMessageEvent', () {
    blocTest<ChatBloc, ChatState>(
      'emits streaming states with accumulated text then loaded state',
      build: buildBloc,
      setUp: () {
        // Configure the repository to return a stream of three chunks
        when(() => mockRepository.sendMessage(any()))
            .thenAnswer((_) => Stream.fromIterable([
              'Hello',         // first chunk
              'Hello world',   // second chunk (accumulated)
              'Hello world!',  // final chunk (fully accumulated)
            ]));
      },
      act: (bloc) => bloc.add(
        SendMessageEvent(message: 'Hi', userId: 'user123'),
      ),
      expect: () => [
        // First: a streaming state with empty content
        isA<ChatStreaming>().having(
          (s) => s.streamingContent,
          'streamingContent',
          equals(''),
        ),
        // Then: streaming states for each chunk
        isA<ChatStreaming>().having(
          (s) => s.streamingContent,
          'streamingContent',
          equals('Hello'),
        ),
        isA<ChatStreaming>().having(
          (s) => s.streamingContent,
          'streamingContent',
          equals('Hello world'),
        ),
        isA<ChatStreaming>().having(
          (s) => s.streamingContent,
          'streamingContent',
          equals('Hello world!'),
        ),
        // Finally: a loaded state with the complete message in the list
        isA<ChatLoaded>().having(
          (s) => s.messages.last.content,
          'last message content',
          equals('Hello world!'),
        ),
      ],
    );

    blocTest<ChatBloc, ChatState>(
      'emits error state when repository throws AIContentBlockedException',
      build: buildBloc,
      setUp: () {
        when(() => mockRepository.sendMessage(any()))
            .thenAnswer((_) => Stream.error(
              const AIContentBlockedException(
                'This response could not be generated.',
              ),
            ));
      },
      act: (bloc) => bloc.add(
        SendMessageEvent(message: 'A blocked prompt', userId: 'user123'),
      ),
      expect: () => [
        isA<ChatStreaming>(), // initial loading state
        isA<ChatError>().having(
          (s) => s.errorMessage,
          'errorMessage',
          equals('This response could not be generated.'),
        ),
      ],
    );

    blocTest<ChatBloc, ChatState>(
      'emits error state when rate limit is exceeded',
      build: buildBloc,
      setUp: () {
        // Override the default to return false for this test
        when(() => mockRateLimiter.canMakeRequest(any())).thenReturn(false);
      },
      act: (bloc) => bloc.add(
        SendMessageEvent(message: 'Any message', userId: 'user123'),
      ),
      expect: () => [
        isA<ChatError>().having(
          (s) => s.errorMessage,
          'errorMessage',
          contains('Daily limit'),
        ),
      ],
    );

    blocTest<ChatBloc, ChatState>(
      'does not call repository when rate limit is exceeded',
      build: buildBloc,
      setUp: () {
        when(() => mockRateLimiter.canMakeRequest(any())).thenReturn(false);
      },
      act: (bloc) => bloc.add(
        SendMessageEvent(message: 'Any message', userId: 'user123'),
      ),
      verify: (_) {
        verifyNever(() => mockRepository.sendMessage(any()));
      },
    );
  });
}

blocTest<ChatBloc, ChatState>(...) is the primary tool from bloc_test. It takes a build function that creates the Bloc, a setUp that configures mocks specific to this test, an act that triggers events on the Bloc, and an expect list that declares the sequence of states the Bloc should emit. The test fails if the actual emitted sequence doesn't match the expected sequence exactly.

isA<ChatStreaming>().having((s) => s.streamingContent, 'streamingContent', equals('Hello')) uses the having matcher to assert both the type and a specific field's value in one expression. isA<ChatStreaming>() alone would match any ChatStreaming, regardless of its content. The .having(...) chain drills into the specific field that matters for this test step.

Stream.fromIterable([...]) creates a synchronous stream that emits all three values in sequence without any delay. The blocTest infrastructure handles the async processing correctly, so synchronous streams work fine here.

Stream.error(...) creates a stream that immediately errors with the given exception, simulating the scenario where the repository's stream fails. The Bloc should catch this through the onError callback in emit.forEach and emit a ChatError state.

Golden Tests for AI-Rendered Content

What Golden Tests Are and Why AI Features Need Them

A golden test captures a screenshot of a widget's rendered output and saves it as a "golden file." Future test runs render the same widget and compare the output pixel-by-pixel against the saved golden. If anything in the visual output changes (layout, colors, font sizes, new elements), the test fails.

AI features need golden tests for a specific reason: the output is rendered as Markdown. Your chat screen probably uses flutter_markdown to render bold text, code blocks, bullet lists, and links that Gemini includes in its responses. Markdown rendering is visually complex and easy to accidentally break. A golden test for the rendered output of a typical AI response catches layout regressions that unit and widget tests can't.

Setting Up golden_toolkit

// test/golden/chat_screen/chat_screen_golden_test.dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
import 'package:your_app/features/ai_chat/widgets/ai_message_bubble.dart';

void main() {
  // loadAppFonts()