diff --git a/pubspec.yaml b/pubspec.yaml index 05511bcd..946dd016 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,6 +31,7 @@ dependencies: web: ^1.0.0 dev_dependencies: + analyzer: ^6.7.0 build_runner: ^2.4.10 flutter_test: sdk: flutter diff --git a/test/TESTING_README.mdx b/test/TESTING_README.mdx new file mode 100644 index 00000000..0789e872 --- /dev/null +++ b/test/TESTING_README.mdx @@ -0,0 +1,84 @@ +# Testing Conventions Flutter Components + +### Helper Functions + +As you are writing tests think about helper function you could write and add them to the `test_utils/utils.dart` file. This will help you and others write tests faster and more consistently. + +- For golden tests + `goldenTest(GoldenFiles goldenFile, Widget widget, Type widgetType, String fileName, {bool darkMode = false})` +- For debugFillProperties tests + `debugFillPropertiesTest(Widget widget, Map debugFillProperties)` + +### Groups + +- Accessibility Tests + Semantic labels, touch areas, contrast ratios, etc. +- Content Tests + Finds the widget, parameter statuses, etc. +- Dimensions Tests + Size, padding, margin, alignment, etc. +- Styling Tests + Rendered colors, fonts, borders, radii etc. +- Interaction Tests + Gesture recognizers, taps, drags, etc. +- Golden Tests + Compares the rendered widget with the golden file. Use the `goldenTest()` function from test_utils/utils.dart. +- Performance Tests + Animation performance, rendering performance, data manupulation performance, etc. + +### Testing File Template + +``` +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../../test_utils/test_app.dart'; +import '../../../test_utils/tolerant_comparator.dart'; +import '../../../test_utils/utils.dart'; + +void main() { + const String componentName = 'ENTER_COMPONENT_NAME (e.g. ZetaButton)'; + const String parentFolder = 'ENTER_PARENT_FOLDER (e.g. button)'; + + const goldenFile = GoldenFiles(component: parentFolder); + setUpAll(() { + goldenFileComparator = TolerantComparator(goldenFile.uri); + }); + + group('$componentName Accessibility Tests', () {}); + group('$componentName Content Tests', () { + final debugFillProperties = { + '': '', + }; + debugFillPropertiesTest( + widget, + debugFillProperties, + ); + }); + group('$componentName Dimensions Tests', () {}); + group('$componentName Styling Tests', () {}); + group('$componentName Interaction Tests', () {}); + group('$componentName Golden Tests', () { + goldenTest(goldenFile, widget, widgetType, 'PNG_FILE_NAME'); + }); + group('$componentName Performance Tests', () {}); +} +``` + +### Test Visibility Table + +You can find the test visibility table at the following path: 'test/scripts/output/test_table.mdx' + +To generate the table run the following command from the root of the project: + +```bash +dart test/scripts/test_counter.dart +``` + +#### Visibility Excel Sheet + +https://zebra-my.sharepoint.com/:x:/p/de7924/Ea0l7BF7AzJJoBVPrg4cIswBZRyek6iNT3zzwDcLn-5ZGg?e=NTJIZU diff --git a/test/scripts/test_counter.dart b/test/scripts/test_counter.dart new file mode 100644 index 00000000..72dafe74 --- /dev/null +++ b/test/scripts/test_counter.dart @@ -0,0 +1,241 @@ +import 'dart:io'; + +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; + +import 'utils/utils.dart'; + +/// A visitor that recursively visits AST nodes to identify and process test groups. +/// +/// This class extends `RecursiveAstVisitor` and overrides necessary methods +/// to traverse the abstract syntax tree (AST) of Dart code. It is specifically +/// designed to locate and handle test groups within the code, which are typically +/// defined using the `group` function in test files. +/// +/// By implementing this visitor, you can analyze the structure of your test files, +/// extract information about test groups, and perform any required operations on them. +/// This can be useful for generating reports, performing static analysis, or +/// automating certain tasks related to your test suite. +class TestGroupVisitor extends RecursiveAstVisitor { + final List> groups = []; + + /// Visits a method invocation node in the abstract syntax tree (AST). + /// + /// This method is typically used in the context of traversing or analyzing + /// Dart code. It processes a [MethodInvocation] node, which represents + /// a method call in the source code. + /// + /// [node] - The [MethodInvocation] node to visit. + /// The method checks if the method invocation is one of the following: + /// - `group` + /// - `testWidgets` + /// - `test` + /// - `goldenTest` + /// - `debugFillPropertiesTest` + /// Then it extracts the group name and test names from the method invocation. + /// + /// - Parameter node: The [MethodInvocation] node to visit. + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.hasNullParentNode()) { + if (node.methodIsOneOf(['group'])) { + final groupName = node.getGroupName(); + final groupBody = node.argumentList.arguments.last; + + final tests = >[]; + + if (groupBody is FunctionExpression) { + final body = groupBody.body; + if (body is BlockFunctionBody) { + body.block.visitChildren(TestVisitor(tests)); + } + } + + groups.add({ + 'group': groupName, + 'tests': tests, + }); + } else if (node.methodIsOneOf(['testWidgets', 'test', 'goldenTest', 'debugFillPropertiesTest'])) { + final testName = node.getTestName(); + + if (groups.any((el) => el['group'] == 'unorganised')) { + final unorganisedGroup = groups.firstWhere((el) => el['group'] == 'unorganised'); + (unorganisedGroup['tests'] as List).add({ + 'name': testName, + }); + } else { + groups.add({ + 'group': 'unorganised', + 'tests': [ + { + 'name': testName, + }, + ], + }); + } + } + } + super.visitMethodInvocation(node); + } +} + +/// A visitor class that extends `RecursiveAstVisitor` to traverse +/// the Abstract Syntax Tree (AST) of Dart code. This class is specifically +/// designed to extract test names from test files. +/// +/// The `TestVisitor` class overrides necessary methods to visit nodes +/// in the AST and identify test definitions. It collects the names of +/// the tests, which can then be used for various purposes such as +/// generating test reports or running specific tests. +/// +class TestVisitor extends RecursiveAstVisitor { + TestVisitor(this.tests); + + final List> tests; + + /// Visits a method invocation node in the abstract syntax tree (AST). + /// This method checks if the method invocation is one of the following: + /// - `testWidgets` + /// - `test` + /// - `goldenTest` + /// - `debugFillPropertiesTest` + /// Then it extracts the test name from the method invocation. + /// + /// [node] - The [MethodInvocation] node to visit. + @override + void visitMethodInvocation(MethodInvocation node) { + if (node.methodIsOneOf(['testWidgets', 'test'])) { + final testName = node.getTestName(); + tests.add({ + 'name': testName, + }); + } else if (node.methodIsOneOf(['debugFillPropertiesTest'])) { + tests.add({ + 'name': node.getMethodName(), + }); + } else if (node.methodIsOneOf(['goldenTest'])) { + tests.add({ + 'name': node.toString(), + }); + } + + super.visitMethodInvocation(node); + } +} + +/// Generates an MDX (Markdown Extended) table representation of the test counts. +/// +/// The function takes a nested map where the outer map's keys are test group names, +/// and the inner map's keys are test names with their corresponding integer counts. +/// +/// Example input: +/// ```dart +/// { +/// "test/src/components\\banner\\banner_test.dart": { +/// "Accessibility": 3, +/// }, +/// } +/// ``` +/// +/// Example output: +/// ```mdx +/// | Component | Accessibility | Content | Dimensions | Styling | Interaction | Golden | Performance | Unorganised | Total Tests | +/// | ----------- | ------------- | ------- | ---------- | ------- | ----------- | ------ | ----------- | ----------- | ----------- | +/// | Banner | 3 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 3 | +/// | Total Tests | 3 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 3 | +/// ``` +/// +/// Parameters: +/// - `testCount`: A map where the keys are test group names and the values are maps +/// of test names with their corresponding counts. +/// +/// Returns: +/// - A string in MDX format representing the test counts in a table with totals. +String generateMDX(Map> testCount) { + final Map groupTotals = { + 'Accessibility': 0, + 'Content': 0, + 'Dimensions': 0, + 'Styling': 0, + 'Interaction': 0, + 'Golden': 0, + 'Performance': 0, + 'unorganised': 0, + }; + + final List data = [ + '| Component | Accessibility | Content | Dimensions | Styling | Interaction | Golden | Performance | Unorganised | Total Tests |', + '| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |', + ] + ..addComponentRows(testCount, groupTotals) + ..addCategoryTotalRow(testCount, groupTotals); + + return data.join('\n'); +} + +/// Parses a collection of test files and returns a map where the keys are +/// strings and the values are lists of maps containing dynamic data. +/// +/// The function takes an iterable of [FileSystemEntity] objects representing +/// the test files to be parsed. It processes these files asynchronously and +/// returns a [Future] that completes with a map. Each key in the map is a +/// string, and each value is a list of maps with string keys and dynamic values. +/// +/// - Parameter testFiles: An iterable collection of [FileSystemEntity] +/// objects representing the test files to be parsed. +/// - Returns: A [Future] that completes with a map where the keys are strings +/// and the values are lists of maps containing dynamic data. +Future parseTestFiles(Iterable testFiles) async { + final TestGroups testGroups = {}; + for (final FileSystemEntity file in testFiles) { + final contents = await File(file.path).readAsString(); + final parseResult = parseString(content: contents); + final visitor = TestGroupVisitor(); + parseResult.unit.visitChildren(visitor); + testGroups[file.path] = visitor.groups; + } + return testGroups; +} + +/// Counts the number of tests in each test group and returns a map with the counts. +/// +/// - Parameters: +/// - testGroups: A map where the keys are group names and the values are lists of test maps. +/// - Returns: A map where the keys are component names and the values are maps containing the count of tests in each test group. +Map> countTests(TestGroups testGroups) { + final TestCount testCount = {}; + testGroups.forEach((filePath, groups) { + final Map groupCounts = {}; + for (final group in groups) { + final groupName = group['group'] as String; + final tests = group['tests'] as List; + groupCounts[groupName] = tests.length; + } + testCount[filePath] = groupCounts; + }); + return testCount; +} + +void main() async { + // check for output directory and create if it doesn't exist + final Directory outputDirectory = await outputPath('test/scripts/output'); + + // get all test files + final Iterable testFiles = getTestFiles('test/src/components'); + + // parse each test file and extract test groups + final TestGroups testGroups = await parseTestFiles(testFiles); + + // write test groups to file + await writeJSONToFile('${outputDirectory.path}/test_groups.json', testGroups); + + // count the number of tests in each group + final TestCount testCount = countTests(testGroups); + + // write test counts to file + await writeJSONToFile('${outputDirectory.path}/test_counts.json', testCount); + + // generate MDX table + await writeMDXToFile('${outputDirectory.path}/test_table.mdx', generateMDX(testCount)); +} diff --git a/test/scripts/utils/utils.dart b/test/scripts/utils/utils.dart new file mode 100644 index 00000000..bcfc30c3 --- /dev/null +++ b/test/scripts/utils/utils.dart @@ -0,0 +1,249 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:analyzer/dart/ast/ast.dart'; + +/// A typedef for a map that associates a string key with a list of maps. +/// Each map in the list contains string keys and dynamic values. +/// +/// This can be used to group test cases or data sets by a specific category or identifier. +/// +/// Example: +/// ```dart +/// TestGroups testGroups = "path/to/test/file_test.dart": [ +/// { +/// 'group': 'Accessibility', +/// 'tests': [ +/// {'name': 'value1'}, +/// {'name': 'value2'}, +/// ], +/// }, +/// { +/// 'group': 'Content', +/// 'tests': [ +/// {'name': 'value1'}, +/// ], +/// }, +/// }; +/// ``` +typedef TestGroups = Map>>; + +/// A typedef for a nested map structure that counts tests. +/// +/// The outer map uses a `String` as the key, which represents a test file path. +/// The inner map also uses a `String` as the key, which represents a test category. +/// The value of the inner map is an `int` that represents the count of occurrences or results for that specific test category. +/// +/// Example: +/// ```dart +/// TestCount testCount = { +/// "path/to/test/file_test.dart": { +/// "Accessibility": 3, +/// "Content": 2, +/// }, +/// }; +/// ``` +typedef TestCount = Map>; + +extension NodeExtension on MethodInvocation { + /// Checks if the current node has a null parent node. + /// + /// Returns `true` if the parent node is null, otherwise `false`. + bool hasNullParentNode() { + return parent!.parent!.thisOrAncestorMatching((node) => node is MethodInvocation) == null; + } + + /// Retrieves and sanitizes the name of the test group. + /// + /// Returns: + /// A [String] containing the sanitized name of the group. + String getGroupName() { + return argumentList.arguments.first + .toString() + .replaceAll("'", '') + .replaceAll(r'$componentName ', '') + .replaceAll(' Tests', ''); + } + + /// Retrieves and sanitizes the name of the test. + /// + /// Returns: + /// A [String] containing the sanitized name of the test. + String getTestName() { + return argumentList.arguments.first.toString().replaceAll("'", ''); + } + + /// Returns the name of the method as a string. + /// + /// Returns: + /// A [String] representing the method name. + String getMethodName() { + return methodName.name; + } + + /// Checks if the current method is one of the specified methods. + /// + /// This function takes a list of method names and checks if the current + /// method is included in that list. + /// + /// - Parameter methods: A list of method names to check against. + /// - Returns: `true` if the current method is one of the specified methods, + /// otherwise `false`. + bool methodIsOneOf(List methods) { + return methods.contains(methodName.name); + } +} + +extension StringExtension on String { + /// Capitalizes the first letter of the string. + /// + /// Returns a new string with the first letter converted to uppercase + /// and the remaining letters unchanged. + /// + /// Example: + /// + /// ```dart + /// String text = "hello"; + /// String capitalizedText = text.capitalize(); + /// print(capitalizedText); // Output: Hello + /// ``` + String capitalize() { + if (isEmpty) return this; + return this[0].toUpperCase() + substring(1); + } + + /// Capitalizes the first letter of each word in a string. + /// + /// This method splits the string by spaces, capitalizes the first letter + /// of each word, and then joins the words back together with spaces. + /// + /// Returns a new string with each word capitalized. + String capitalizeEachWord() { + return split(' ').map((word) => word.capitalize()).join(' '); + } +} + +/// Extracts the component name from a given test file path. +/// +/// This function takes a test file path as input and returns the component name +/// inferred from the path. The component name is extracted by splitting the path +/// and removing the file extension and test suffix. +/// +/// Example: +/// ```dart +/// String componentName = getComponentNameFromTestPath('test/src/components/comms_button/comms_button_test.dart'); +/// print(componentName); // Output: Comms Button +/// ``` +/// +/// - Parameter path: The file path from which to extract the component name. +/// - Returns: The component name as a string. +String getComponentNameFromTestPath(String path) { + return path.split(r'\').last.split('_test').first.replaceAll('_', ' ').capitalizeEachWord(); +} + +/// Returns a [Future] that completes with a [Directory] at the specified [path]. +/// +/// This function takes a [String] [path] and asynchronously returns a [Directory] +/// object representing the directory at the given path. +/// +/// Example: +/// ```dart +/// Directory dir = await outputPath('/path/to/directory'); +/// ``` +Future outputPath(String path) async { + final outputDirectory = Directory(path); + if (!outputDirectory.existsSync()) { + await outputDirectory.create(recursive: true); + } + return outputDirectory; +} + +/// Retrieves an iterable of file system entities from the specified path. +/// +/// This function takes a [path] as a string and returns an [Iterable] of +/// [FileSystemEntity] objects representing the test files located at the given path. +/// +/// - Parameter path: The path to the directory from which to retrieve the files. +/// - Returns: An iterable collection of file system entities found at the specified path. +Iterable getTestFiles(String path) { + final testDirectory = Directory(path); + return testDirectory + .listSync(recursive: true) + .where((entity) => entity is File && entity.path.endsWith('_test.dart')); +} + +/// Writes the given JSON content to a file at the specified path. +/// +/// This function takes a file path and JSON content, and writes the content +/// to the file asynchronously. If the file does not exist, it will be created. +/// +/// [path] The file path where the JSON content should be written. +/// [content] The JSON content to write to the file. +Future writeJSONToFile(String path, dynamic content) async { + final jsonOutputGroups = jsonEncode(content); + final outputFileGroups = File(path); + await outputFileGroups.writeAsString(jsonOutputGroups); +} + +/// Writes the given MDX data to a file at the specified path. +/// +/// This function asynchronously writes the provided MDX data to a file +/// located at the given path. If the file does not exist, it will be created. +/// +/// [path] The file path where the MDX data should be written. +/// [mdxData] The MDX data to write to the file. +Future writeMDXToFile(String path, String mdxData) async { + final mdxFile = File(path); + await mdxFile.writeAsString(mdxData); +} + +extension ListExtension on List { + /// Adds rows of components to the specified target. + /// + /// This function iterates over the test count and group totals + /// to add the component rows to the target. + void addComponentRows( + Map> testCount, + Map groupTotals, + ) { + testCount.forEach((filePath, groups) { + final componentName = getComponentNameFromTestPath(filePath); + + int unorganisedTestsInComponent = 0; + groups.forEach((key, value) { + if (!groupTotals.keys.contains(key)) { + unorganisedTestsInComponent += value; + } + }); + unorganisedTestsInComponent += groups['unorganised'] ?? 0; + + final totalTestsForComponent = groups.values.fold(0, (previousValue, element) => previousValue + element); + + return add( + '| $componentName | ${groups['Accessibility'] ?? 0} | ${groups['Content'] ?? 0} | ${groups['Dimensions'] ?? 0} | ${groups['Styling'] ?? 0} | ${groups['Interaction'] ?? 0} | ${groups['Golden'] ?? 0} | ${groups['Performance'] ?? 0} | $unorganisedTestsInComponent | $totalTestsForComponent |', + ); + }); + } + + /// Adds a total row for a category in the data table. + /// + /// This method calculates the total for a specific category and appends + /// a row to the data table displaying the totals. + void addCategoryTotalRow( + Map> testCount, + Map groupTotals, + ) { + testCount.forEach((filePath, groups) { + groups.forEach((key, value) { + if (!groupTotals.keys.contains(key)) { + groupTotals['unorganised'] = groupTotals['unorganised']! + value; + } else { + groupTotals[key] = groupTotals[key]! + value; + } + }); + }); + return add( + '| Total Tests | ${groupTotals['Accessibility']} | ${groupTotals['Content']} | ${groupTotals['Dimensions']} | ${groupTotals['Styling']} | ${groupTotals['Interaction']} | ${groupTotals['Golden']} | ${groupTotals['Performance']} | ${groupTotals['unorganised']} | ${groupTotals.values.fold(0, (previousValue, element) => previousValue + element)} |', + ); + } +} diff --git a/test/testing_conventions.mdx b/test/testing_conventions.mdx deleted file mode 100644 index 4146566c..00000000 --- a/test/testing_conventions.mdx +++ /dev/null @@ -1,9 +0,0 @@ -## Groups - -- Accessibility Tests -- Content Tests -- Dimensions Tests -- Styling Tests -- Interaction Tests -- Golden Tests -- Performance Tests