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 Object subclass: #GtExample instanceVariableNames: 'providerClass methodClass selector label description form exceptions subjects children properties after problems timeLimit' classVariableNames: '' package: 'GToolkit-Examples-Core' . 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 Object subclass: #BrToggleExamples instanceVariableNames: '' classVariableNames: '' package: 'Brick-Examples-Toggle' 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.