Testing

Rendered Markdown is mostly spans and widgets, not a collection of standalone Text nodes. Test the rendered structure and behavior rather than assuming a plain paragraph.

Find the rendered text

find.text('hello') often fails because the visible text belongs to a span tree insideRichText. Read the text from each rich-text subtree instead. Use a predicate rather thanfind.byType(RichText), because paragraphs needing inline-code or bidirectional handling can be rendered through a RichText subclass.

gpt_markdown_test.dart
1String plainText(WidgetTester tester) { 2 final buffer = StringBuffer(); 3 for (final richText in tester.widgetList<RichText>( 4 find.byWidgetPredicate((widget) => widget is RichText), 5 )) { 6 buffer.write(richText.text.toPlainText(includePlaceholders: false)); 7 } 8 return buffer.toString(); 9} 10 11testWidgets('renders Markdown text', (tester) async { 12 await tester.pumpWidget(const MaterialApp( 13 home: Scaffold(body: GptMarkdown('hello **world**')), 14 )); 15 16 expect(plainText(tester), contains('hello world')); 17})

find.text still works for content deliberately rendered in a WidgetSpan—such as a custom chip, code block, or table cell—because those contain actual Text widgets. It is span content inside a Markdown paragraph that requires the helper.

Builders, patterns, and remounting

Styles, patterns, and component lists are compared and can update live. Builder closures are intentionally not compared: closures cannot be meaningfully compared without defeating the render cache. Give the widget a changing key when replacing a builder at runtime.

builder_test.dart
1// A new closure is not a meaningful rendering identity. 2// Changing it at runtime does not regenerate the parsed spans: 3await tester.pumpWidget(app(codeBuilder: builderA)); 4await tester.pumpWidget(app(codeBuilder: builderB)); // still builderA 5 6// Remount when the builder identity should change: 7GptMarkdown( 8 text, 9 key: ValueKey(builderId), 10 codeBuilder: builder, 11)

Text scale and expected overflow

Long code lines and long headings cannot wrap at raised text scales. Those intentional horizontal overflows are not necessarily a rendering regression. Tables and block math have their own scroll treatment. Drain the expected exception only in the focused test; do not silence exceptions globally or new overflow bugs will be hidden.

overflow_test.dart
1await tester.pumpAndSettle(); 2while (tester.takeException() != null) {}
Do not compare total paragraph height as a scale ratio. A scaled paragraph can wrap to more lines. Measure line height or test with a wide surface where it cannot wrap.

Streaming tests

A streaming reveal is an animation, so a normal pumpAndSettle can wait for the whole answer. Pump one frame when you are asserting an intermediate state, or disable animation when the test is about parsing or layout instead.

streaming_test.dart
1// Test one frame of the reveal: 2await tester.pump(const Duration(milliseconds: 16)); 3 4// Or let a finite reply finish: 5await tester.pumpAndSettle(const Duration(seconds: 5)); 6 7// For a test unrelated to animation, take the static path: 8GptMarkdown(text, animation: GptMarkdownAnimation.none) 9// or: 10GptMarkdown(text, animation: GptMarkdownAnimation.fade, isStreaming: false)

Test styles at the component boundary

Assert the widget that consumes a resolved style, not a screenshot pixel. Also test precedence: define one field on the app theme and another on the widget-level style sheet, then verify that both survive the merge.

styles_test.dart
1testWidgets('the quote bar follows the theme', (tester) async { 2 await tester.pumpWidget( 3 MaterialApp( 4 theme: ThemeData(extensions: [ 5 GptMarkdownThemeData( 6 brightness: Brightness.light, 7 styleSheet: const GptMarkdownStyleSheet( 8 blockQuote: BlockQuoteStyle(barWidth: 7), 9 ), 10 ), 11 ]), 12 home: const Scaffold(body: GptMarkdown('> quoted')), 13 ), 14 ); 15 16 final quote = tester.widget<BlockQuoteWidget>( 17 find.byType(BlockQuoteWidget), 18 ); 19 expect(quote.width, 7); 20})

Goldens, screenshots, and documentation snippets

Linux goldens

Default-look goldens run on Linux because text rasterization differs by platform. A tolerance was rejected because it hid real changes. Regenerate only after reviewing an intentional visual change.

gh workflow run goldens.yml --ref my-branch gh run watch git pull

README images

Showcase images are documentation assets made through the widget test harness, not goldens. Regenerate them with ./scripts/screenshots.sh, commit the PNGs, and bump every README image's ?v= URL version because GitHub caches proxied images by URL.

Compiled docs

Package documentation snippets live in test/docs/snippets_test.dart and compile in the test suite, so an API rename cannot quietly leave the source guides stale. Add a snippet fixture whenever package documentation gains a new API example.

Never refresh a red golden blindly.

--update-goldens records the current pixels, including regressions. Inspect the expected, actual, and diff images first; on CI they are in the golden-failures artifact. Use -f commit=false when the workflow should return images as artifacts instead of committing them.