diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d79863 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.packages +.pub +pubspec.lock diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..33c8be4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,37 @@ +Want to contribute? Great! First, read this page (including the small print at +the end). + +### Before you contribute + +Before we can use your code, you must sign the +[Google Individual Contributor License Agreement](https://cla.developers.google.com/about/google-individual) +(CLA), which you can do online. The CLA is necessary mainly because you own the +copyright to your changes, even after your contribution becomes part of our +codebase, so we need your permission to use and distribute your code. We also +need to be sure of various other things—for instance that you'll tell us if you +know that your code infringes on other people's patents. You don't have to sign +the CLA until after you've submitted your code for review and a member has +approved it, but you must do it before we can put your code into our codebase. + +Before you start working on a larger contribution, you should get in touch with +us first through the issue tracker with your idea so that we can help out and +possibly guide you. Coordinating up front makes it much easier to avoid +frustration later on. + +### Code reviews + +All submissions, including submissions by project members, require review. + +### File headers + +All files in the project must start with the following header. + + // Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file + // for details. All rights reserved. Use of this source code is governed by a + // BSD-style license that can be found in the LICENSE file. + +### The small print + +Contributions made by corporations are covered by a different agreement than the +one above, the +[Software Grant and Corporate Contributor License Agreement](https://developers.google.com/open-source/cla/corporate). diff --git a/README.md b/README.md new file mode 100644 index 0000000..34ec1af --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +Keep multiline test case inputs and outputs out of your Dart test code, and +write them directly as text files. + +## Example + +Take the following test case for String's `toUpperCase()`: + +```dart +void main() { + test('Works on multiline strings', () { + var input = r'''This is the first line. +This is another line. + +This is another paragraph.'''; + + var expectedOutput = r'''THIS IS THE FIRST LINE. +THIS IS ANOTHER LINE. + +THIS IS ANOTHER PARAGRAPH.'''; + + expect(input.toUpperCase(), equals(expectedOutput)); + }); +} +``` + +Multiline strings break the visual flow of code indentations. Additionally, when +newline characters are important, you may not be able to just write a newline +after `r'''`, or before the closing `''';`, which makes it hard to scan for +where multiline Strings begin and end. Instead, let's add write this test case, +and a few more, in a separate text file: + +```none +>>> Works on simple strings +This is a single line. +<<< +THIS IS A SINGLE LINE. +>>> Does nothing to upper case strings +THIS IS ALREADY UPPER CASE. +<<< +THIS IS ALREADY UPPER CASE. +>>> Works on multiline strings +This is the first line. +This is another line. + +This is another paragraph. +<<< +THIS IS THE FIRST LINE. +THIS IS ANOTHER LINE. + +THIS IS ANOTHER PARAGRAPH. +``` + +We can quickly create tests over these data cases with some Dart: + +```dart +library touppercase.tests; + +void main() { + for (var dataCase in dataCasesUnder(library: #touppercase.tests)) { + test(dataCase.testDescription, () { + var actualOutput = dataCase.input.toUpperCase(); + expect(actualOutput, equals(dataCase.expectedOutput)); + }); + } +} +``` + +If our test is located at `to_upper_case_package/test/to_upper_case_test.dart`, +then `dataCasesUnder` will look for files ending in `.unit` in the same +directory as the `library`. So our text file with the test cases should be, +perhaps, `to_upper_case_package/test/cases.unit`. + +(Note: Why the weird library symbols? This is the simplest way to locate a +directory or file relative to Dart source. Hopefully a temporary issue.) + +## When to use + +This package is not very broad purposed, and is probably appropriate for only +very specific functionality testing. It has been very useful for testing +packages that primarily transform one block of text into another block of text. + +This package is also mostly useful when the different ways to configure the +processing is very limited. In these cases, the Dart code that receives the test +cases and asserts over them can be very brief, and developers can focus on just +writing the data test cases. + +This package is also most useful when whitespace is _ever_ significant. In cases +like this, multiline strings cannot fake indentation, and it may be harder for +the developer to track whitespace when writing a test case. In contrast, it is +probably much easier to write input and expected output in simple text blocks +in simple text files. Examples would include a Markdown parser that needs to +parse indented list continuations or indented code blocks, and text formatters +that need to test specific indentation in the output. + +Disclaimer: this is not an official Google product. diff --git a/lib/expected_output.dart b/lib/expected_output.dart new file mode 100644 index 0000000..f1d856e --- /dev/null +++ b/lib/expected_output.dart @@ -0,0 +1,108 @@ +import 'dart:io'; +import 'dart:mirrors'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +// TODO(srawlins): add a recursive option +/// Parse and yield data cases (each a [DataCase]) from [directory]. +/// +/// By default, only read data cases from files with a `.unit` extension. +Iterable dataCases({ + String directory, + String extension: 'unit', +}) sync* { + var entries = + new Directory(directory).listSync(recursive: false, followLinks: false); + for (var entry in entries) { + if (!entry.path.endsWith(extension)) { + continue; + } + + var file = + p.basename(entry.path).replaceFirst(new RegExp('\.$extension\$'), ''); + + // Explicitly create a File, in case the entry is a Link. + var lines = new File(entry.path).readAsLinesSync(); + + var i = 0; + while (i < lines.length) { + var description = + lines[i++].replaceFirst(new RegExp(r'>>>\s*'), '').trim(); + if (description == '') { + description = 'line ${i+1}'; + } else { + description = 'line ${i+1}: $description'; + } + + var input = ''; + while (!lines[i].startsWith('<<<')) { + input += lines[i++] + '\n'; + } + + var expectedOutput = ''; + while (++i < lines.length && !lines[i].startsWith('>>>')) { + expectedOutput += lines[i] + '\n'; + } + + var dataCase = new DataCase( + directory: p.basename(directory), + file: file, + description: description, + input: input, + expectedOutput: expectedOutput); + yield dataCase; + } + } +} + +/// Parse and yield data cases (each a [DataCase]) from the directory containing +/// [library]. +/// +/// By default, only read data cases from files with a `.unit` extension. +/// +/// The typical use case of this method is to declare a library at the top of a +/// Dart test file, then reference the symbol with a pound sign. Example: +/// +/// ```dart +/// library my_package.test.this_test; +/// +/// import 'package:expected_output/expected_output.dart'; +/// import 'package:test/test.dart'; +/// +/// void main() { +/// for (var dataCase in dataCasesUnder(library: #my_package.test.this_test)) { +/// // ... +/// } +/// } +/// ``` +Iterable dataCasesUnder( + {Symbol library, String extension: 'unit'}) sync* { + var directory = + p.dirname(currentMirrorSystem().findLibrary(library).uri.toFilePath()); + for (var dataCase + in expectedOutputs(directory: directory, extension: extension)) { + yield dataCase; + } +} + +/// All of the data pertaining to a particular test case, namely the [input] and +/// [expectedOutput]. +class DataCase { + final String directory; + final String file; + final String description; + final String input; + final String expectedOutput; + + DataCase( + {this.directory, + this.file, + this.description, + this.input, + this.expectedOutput}); + + /// A good standard description for `test()`, derived from the data directory, + /// the particular data file, and the test case description. + String get testDescription => [directory, file, description].join(' '); +} diff --git a/test/simple_data/cases.unit b/test/simple_data/cases.unit new file mode 100644 index 0000000..337cb5e --- /dev/null +++ b/test/simple_data/cases.unit @@ -0,0 +1,22 @@ +>>> +input 1 +<<< +output 1 +>>> description with a space in the front. +input 2 +<<< +output 2 +>>>description without a space in the front. +input 3 +<<< +output 3 +>>> description with a few spaces in the front. +input 4 +<<< +output 4 +>>> multiline input and output +input +five +<<< +output +five diff --git a/test/simple_data/cases2.unit b/test/simple_data/cases2.unit new file mode 100644 index 0000000..2862240 --- /dev/null +++ b/test/simple_data/cases2.unit @@ -0,0 +1,4 @@ +>>> a second unit file +an input +<<< +an output diff --git a/test/simple_test.dart b/test/simple_test.dart new file mode 100644 index 0000000..38147a1 --- /dev/null +++ b/test/simple_test.dart @@ -0,0 +1,69 @@ +library expected_output.test.simple_test; + +import 'dart:mirrors'; + +import 'package:expected_output/expected_output.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + var iterator; + var dataCase; + var iteratorIsEmpty; + + setUpAll(() { + // Locate the "test" directory. Use mirrors so that this works with the test + // package, which loads this suite into an isolate. + var testDir = p.dirname(currentMirrorSystem() + .findLibrary(#expected_output.test.simple_test) + .uri + .toFilePath()); + iterator = dataCases(directory: p.join(testDir, 'simple_data')).iterator; + }); + + setUp(() { + iteratorIsEmpty = !iterator.moveNext(); + dataCase = iterator.current; + }); + + test('parses case w/o a description', () { + expect(dataCase.directory, endsWith('simple_data')); + expect(dataCase.file, 'cases'); + expect(dataCase.description, 'line 2'); + expect(dataCase.testDescription, 'simple_data cases line 2'); + expect(dataCase.input, 'input 1\n'); + expect(dataCase.expectedOutput, 'output 1\n'); + }); + + test('parses case w/ whitespace after >>>', () { + expect( + dataCase.description, 'line 6: description with a space in the front.'); + }); + + test('parses case w/o whitespace after >>>', () { + expect(dataCase.description, + 'line 10: description without a space in the front.'); + }); + + test('parses case w/ multiple whitespace after >>>', () { + expect(dataCase.description, + 'line 14: description with a few spaces in the front.'); + }); + + test('parses case w/ multiline input and output', () { + expect(dataCase.input, 'input\nfive\n'); + expect(dataCase.expectedOutput, 'output\nfive\n'); + }); + + test('parses case w/o a description', () { + expect(dataCase.directory, endsWith('simple_data')); + expect(dataCase.file, 'cases2'); + expect(dataCase.description, 'line 2: a second unit file'); + expect(dataCase.testDescription, + 'simple_data cases2 line 2: a second unit file'); + }); + + test('the dataCases iterator is empty at the end', () { + expect(iteratorIsEmpty, isTrue); + }); +}