Example-driven development by example
TL;DR
We learn how an Example method combines testing with documentation.
Constructing examples
Let us imagine that we want to explain how to create a file with some content. To store this example, we create a class and a unary method:
Object subclass: #GtExamplesTutorial instanceVariableNames: '' classVariableNames: '' poolDictionaries: '' category: 'GToolkitExamplesTutorial'. GtExamplesTutorial class instanceVariableNames: '' "protocol: #examples" GtExamplesTutorial >> createFileInMemory <gtExample> ^ FileSystem memory workingDirectory / 'sample.txt' writeStreamDo: [ :stream | stream nextPutAll: 'Sample contents' ]; yourself
Apply the changes above either by creating the code manually or by clicking the apply button (checkmark). After doing that, you should see the method here:
The only special thing about the unary method is the pragma <gtExample>
which converts any regular method into a GtExample
. The name of the method can be anything.
An example might be annotated with additional and optional meta-information like #label:
(a title or very short description), #description:
(a long description explaining the intention of the example) or #icon:
. These are useful for tooling purposes.
"protocol: #examples" GtExamplesTutorial >> createFileInMemory <gtExample> <description: 'Create a new file or override an existing file with some contents. Open and close the stream safely'> ^ FileSystem memory workingDirectory / 'sample.txt' writeStreamDo: [ :stream | stream nextPutAll: 'Sample contents' ]; yourself
Once we have an example, we can execute it both as a plain method to get the result:
GtExamplesTutorial new createFileInMemory
or as an example:
(GtExamplesTutorial>>#createFileInMemory) gtExample run returnValue
In both cases, we get the returned object.
Example dependencies
As examples return objects, we can use those objects to compose other objects that in turn can also constitute examples. To support this, examples may be chained to form trees (or even graphs) of depending examples.
Let's look at our example. There are at least three interesting objects in one single method:
[A] the file reference (createFileInMemory
)
[B] the string contents (fileContents
)
[C] the file name (fileName
)
If we want to decompose them, we can use dependencies:
"protocol: #examples" GtExamplesTutorial >> directoryInMemory <gtExample> ^ FileSystem memory workingDirectory "protocol: #examples" GtExamplesTutorial >> fileContents <gtExample> ^ 'Sample contents' "protocol: #examples" GtExamplesTutorial >> fileName <gtExample> ^ 'sample.txt' "protocol: #examples" GtExamplesTutorial >> createFileInMemory <gtExample> <description: 'Create a new file or override an existing file with some contents. Open and close the stream safely'> ^ self directoryInMemory / self fileName writeStreamDo: [ :stream | stream nextPutAll: self fileContents ]; yourself
In the example above, the example [A] depends on the examples [B] and [C]. The dependencies can also be navigated visually:
And you can progammatically inspect multiple such examples:
GtExampleGroup withAll: GtExamplesTutorial gtExamples
Cleaning up after using an example
In the current scenario, the file is created in memory. But, what would happen if the example would create the file on disk? Let's create such an example to explore the situation:
"protocol: #examples" GtExamplesTutorial >> createFileOnDisk <gtExample> ^ self directoryOnDisk / self fileName writeStreamDo: [ :stream | stream nextPutAll: self fileContents ]; yourself "protocol: #examples" GtExamplesTutorial >> directoryOnDisk <gtExample> ^ FileSystem workingDirectory
Try the example.
At first glance, we got the same result. However, one issue with our current scenario is the side effect of leaving a file on disk. In this situation we would like to clean up such artifacts after running an example. We can do that with the declaration of a so called after-method.
"protocol: #examples" GtExamplesTutorial >> deleteFileFromDisk fileOnDisk ensureDelete "protocol: #examples" GtExamplesTutorial >> createFileOnDisk <gtExample> <after: #deleteFileFromDisk> ^ fileOnDisk := self directoryOnDisk / self fileName writeStreamDo: [ :stream | stream nextPutAll: self fileContents ]; yourself Object subclass: #GtExamplesTutorial instanceVariableNames: 'fileOnDisk' classVariableNames: '' poolDictionaries: '' category: 'GToolkitExamplesTutorial'. GtExamplesTutorial class instanceVariableNames: ''
The after-method is a unary method that is executed after the example is executed. In our case, we want the after method to delete the newly created file. To this end, we store the file lazily in an instance variable, and then simply delete it in the after method. When we run the example above, no file is left on the disk after the example has finished running.
After-methods are similar to tearDown-methods in xUnit, however there are subtle and important differences:
The after-method is dedicated (but not exclusive) to a single example while tearDown (in most xUnits) is a global implementation covering multiple test-cases at once.
When multiple examples are chained, each declaring an after-method, multiple after-methods will be performed, in the same order as their corresponding examples.
Since each example intends to focus on a (fine-grained) object and/or behaviour, so does its after-method, only “tearing-down” the subject of the example.
Assertions
Examples, like tests, provide a way to encode and check assumptions, with the help of assertions:
"protocol: #examples" GtExamplesTutorial >> createFileOnDisk <gtExample> <after: #deleteFileFromDisk> fileOnDisk := self directoryOnDisk / self fileName. self assert: fileOnDisk exists not. fileOnDisk writeStreamDo: [ :stream | ]. self assert: fileOnDisk exists. ^ fileOnDisk "protocol: #examples" GtExamplesTutorial >> deleteFileFromDisk self assert: fileOnDisk exists. fileOnDisk ensureDelete. self assert: fileOnDisk exists not
Failing assertions are treated as unexpected exceptions and will result in a failing example (invalid).
Stepping back, we can express everything that we can express with regular xUnit tests:
setUp: We can define shared example objects by using dependencies.
assertions: We can write assertions in each example.
tearDown: We can define behavior to clean up after the example execution (like tearDown).
But, with examples, we can go beyond what is typically possible with xUnit. As we have only examples as a concept, we can write assertions even in "setup" examples or in after methods. Furthermore, we have a way to reuse code at multiple levels, not just two (i.e., setup/test).
To understand the dependencies, let's look at the current state of our examples by inspecting the following code:
GtExampleGroup withAll: GtExamplesTutorial gtExamples
In the map of examples (see the Map
view), we see how examples depend on each other. We see that the top two examples (creating the file in memory and on disk) share one common example that provides the contents.
But, the situation can get more elaborate, enabling more finely grained levels of reuse. For example, take a look at this set of examples from Bloc:
GtExampleGroup withAll: BrToggleExamples gtExamples
Examples as documentation
On the one hand, examples can replace tests. On the other hand, examples can also provide the main building blocks for documentation, too. The comment of the BrToggleExamples
class comment provides a good example of how we can put together a tutorial by glueing live examples with explanations.
All in all, examples provide both a more flexible platform for expressing tests, and once examples are created as part of a typical development effort, they can also serve as critical building blocks for documentation.
Links to Pages containing missing references - allowed failures to allow references to missing classes and methods in the page.