Exemplifying software
Documenting and testing software through examples
In this tutorial we compare unit tests and examples, and look at the economics of designing our unit tests to serve as examples for our systems.
GT Examples is a slim engine that lets you define examples throughout the code and use them for documentation or testing. Through examples, a developer can switch rapidly from the static code to a live environment and program in the presence of objects. Examples then serve as the basis for live documentation.
On tests and examples
Unit tests are commonplace during software development. We write them for various reasons, like understanding what we need to develop, exploring ways to implement a requirement, or documenting how to use an API.
When writing a unit test, we often start by creating and configuring an object capturing a business or a technical aspect, we then perform some actions on that object, continue by checking if those actions had an expected outcome using assertions, and finish by throwing that object away.
So after going through all the work to create an interesting object, we just throw it away. That’s a waste. We could instead promote that object to be an example for a given class and use it to improve our development experience.
Outlook
In this tutorial we explore how to transform unit tests into example using the GT Examples framework for Pharo. We then look at what can be gained from this, especially when taking into account that in Pharo every object can have multiple custom views.
For this demo, we write tests and examples for an application that detects faces within pictures using the Azure Face API, described in another article.
Internally this application is structured using an object-oriented model containing two main entities,Picture
and Face
. A Picture
has a list of Face
objects, and a Face
object knows the Picture
it belongs to. Two other objects are used to store properties related to faces. Internally, a picture has a pictureForm
attribute that stores its graphical representation. Pictures can create this graphical representation by loading it from an URL.
This application, together with all the tests and examples from this tutorial, can be loaded in a Pharo image using the code below. More installation details are on the Github page.
Metacello new
baseline: 'CognitiveServiceDemo';
repository: 'github://chisandrei/cognitive-service-demo/src';
load.
Creating unit tests for the Face API model
Let’s start with a few unit tests checking that our main domain objects are created and initialised correctly, written using the SUnit framework. For the very beginning, we start with a test covering the Face
object.
testFaceInitialization
| face |
face := CSFace new
rectangle: ((860@320) corner: (960@420)).
self assert: face rectangle notNil.
self assert: face containerPicture isNil.
Since we will most likely need to reuse this face object in other tests, for example when adding faces to pictures, we can move the creation of the face object to a dedicated method.
testFaceInitialization
| face |
face := self faceEinstein.
self assert: face rectangle notNil.
self assert: face containerPicture isNil.faceEinstein
^ CSFace new
rectangle: ((860@320) corner: (960@420))
We can run this test, using the Test Runner, to ensure that is actually passes.
Next, let’s write a similar test for the initialisation of a Picture
object.
testPictureInitialization
| picture |
picture := self createEmptyPicture.
self assert: picture url equals: self pictureUrl.
self assert: picture faces isEmpty.
self assert: picture hasFaceStorage equals: false.createEmptyPicture
^ CSPicture new
url: self pictureUrl
Now that the basic initialisation of faces and pictures is covered, we can write a test that links them together. Initially, let’s cover the addition of faces to a picture that does not have a form specifying its graphical representation.
testPictureWithFacesAndNoForm
| picture |
picture := self createPictureWithFaces.
self assert: picture faces size equals: 3.
self assertFacesWithNoFormFor: picture.createPictureWithFaces
| picture |
picture := self createEmptyPicture.
picture ensureFacesStorage.
picture
addFace: self faceEinstein;
addFace: self faceHabicht;
addFace: self faceSolovine.
^ pictureassertFacesWithNoFormFor: aPicture
aPicture faces do: [ :aFace |
self assert: aFace containerPicture equals: aPicture.
self assert: aFace hasFaceForm not ]
Before adding faces to a picture having a form, let’s test attaching the form to a picture. In this case, calling ensurePictureForm
downloads the picture from the given URL. We could also manually set the form or use a mock.
testPictureWithNoFacesAndForm
| picture |
picture := self createEmptyPicture.
self assert: picture pictureForm equals: nil.
picture ensurePictureForm.
self assert: picture pictureForm notNil.
self assert: picture pictureForm extent equals: 1280@921
Finally, we cover the common case of attaching a graphical representation to a picture object that already has information about faces. In this case, every face should have a graphical representation, given that the picture has one.
testPictureWithFacesAndForm
| picture |
picture := self createPictureWithFaces.
picture ensurePictureForm.
self assert: picture faces size equals: 3.
self assertFacesWithFormIn: picture.assertFacesWithFormIn: aPicture
aPicture faces do: [ :aFace |
self assert: aFace containerPicture equals: aPicture.
self assert: aFace hasFaceForm.
self assert: aFace faceForm notNil ]
Now we have a first test suite for our application consisting of 5 tests. We can use the Test Runner to check if these tests pass.
Creating GT Examples for the Face API model
Next, let’s write the same five tests as before, just instead of using SUnit, rely on the GT Examples framework. To show how a GT Example differs from a SUnit test, we take our initial unit test and transform it into an example.
faceEinstein
| face |
face := CSFace new
rectangle: ((860@320) corner: (960@420)).
self assert: face rectangle notNil.
self assert: face containerPicture isNil.
To make it an example, all we need to do is add the gtExample
annotation to the method. This tells GT Examples that we want this method to be considered an example. Then, we can also return the Face
object that we are creating. This is the example object that this example creates.
faceEinstein
<gtExample>
| face |
face := CSFace new
rectangle: ((860@320) corner: (960@420)).
self assert: face rectangle notNil.
self assert: face containerPicture isNil.
^ face
Above, we did not extract the logic for creating the face into a separate method, as we did when we wrote the same test. We do not need to do this, given that we can directly call the method faceEinstein
if we need that face object in another example.
As in the case of SUnit, we can run this example to check if the assertions defined in the example pass. To run examples we can just inspect them. Any group of examples has a view that allows us to run those examples.
We continue with an example for a picture with no face data and an URL.
emptyPicture
<gtExample>
| picture |
picture := CSPicture new
url: self pictureUrl.
self assert: picture url equals: self pictureUrl.
self assert: picture faces isEmpty.
self assert: picture hasFaceStorage equals: false.
^ picture
Next, let’s write an example for a picture that has face information, but no graphical representation. For this example we need a picture and several face objects. As we already have example methods for building faces and an empty picture, we can use them to get those objects.
pictureWithFacesAndNoForm
<gtExample>
| picture |
picture := self emptyPicture.
picture ensureFacesStorage.
picture
addFace: self faceEinstein;
addFace: self faceHabicht;
addFace: self faceSolovine.
self assert: picture faces size equals: 3.
self assertFacesWithNoFormFor: picture.
^ picture
We still need to create two more examples. The first is an example of a picture that has a graphical representation. For this we can start from the pictureWithFacesAndNoForm
example and attach a form to the picture.
pictureWithFacesAndForm
<gtExample>
| picture |
picture := self pictureWithFacesAndNoForm.
picture ensurePictureForm.
self assert: picture faces size equals: 3.
self assertFacesWithFormIn: picture.
^ picture
The second is an example of a picture with no face data and a graphical representation. We can start here from an example of an empty picture.
pictureWithNoFacesAndForm
<gtExample>
| picture |
picture := self emptyPicture.
self assert: picture pictureForm equals: nil.
picture ensurePictureForm.
self assert: picture pictureForm notNil.
self assert: picture pictureForm extent equals: 1280@921.
^ picture
At this point we have a set of seven examples. We can inspect them and check that the assertions they define are passing, like in the case of SUnit.
Examples: a starting point for exploration
Up to this point there is no big difference between writing tests using SUnit or examples using GT Exampes. In both case we have methods constructing objects and checking some properties using assertions. The main difference comes from the way we rely on tools to interact with tests and examples.
In the case of SUnit, using the Test Runner to verify that tests pass marks the end of our interaction with those tests. We could write the tests in the debugger to get access to the live objects, but after the test is run and passes our interaction with the test is over.
Nevertheless, more often than not, being able to continue exploring by looking at related examples, or inspecting the resulting object, is essential to understanding what that example is doing .With examples, the inspector showing us the list of examples is only the beginning of our interaction.
Next, we look at several ways in which we can continue our interaction with these examples directly in the inspector.
Exploring connections between examples
Calling an example method from another example method introduces a dependency between examples. These dependencies are captured by the GT Examples framework and can be explored when inspecting examples. This can help when reasoning about the steps needed to create an example object.
A first way to explore these dependencies is directly in the view showing the list of examples. There we can expand any example that has dependencies.
For the example pictureWithFacesAndForm
we can see that it depends on pictureWithFacesAndNoForm
and that in turn depends on four other examples.
This view contains detailed information about each example and makes it possible to explore dependencies of individual examples. However, it is not suitable for giving us an overview of how examples are interconnected. For that we can switch to the Map view.
As we are in the inspector, in both the Map and Examples views, if we are interested in a particular example we can select it. This will open a new column to the right with that example. This is not the value returned by that example, but an object that models the example.
When looking at an example, by default we get a view that shows us the code of that example. We can also switch views and look at its dependencies.
A deeper dive into dependencies
The two views above are useful to check if examples were run successfully and get a general impression of how examples are structured. They are however not that useful when what we need is to dive more into the code, and also explore how examples are interconnected.
For that, we can switch back and only focus on the Source view. To facilitate exploring the code of example methods, this view allows us to expand in place the code of any other example method. In the view below, we expanded the code of the example pictureWithFacesAndNoForm
and then the one for an example building a Face
object.
One drawback of the Source view is that as we expand examples, we loose the overview of how examples are interconnected. If we need to both look at the source code and still get an overview of how multiple examples are connected, we can use the Connections view. This view provides a different kind of interaction that can expand dependencies on a 2D canvas.
From examples to live objects
Until now we only explored the static side of an example consisting in its source code and relations with other examples. However, the more interesting aspect about an example is that it returns an object. To get to that object we can run the example directly in the inspector and open the created object in a new column to the right.
Then, to learn more about the created object, we can switch to a different view. In the case of a Picture
object, we can look at its list of faces or graphical representation.
We can also navigate to the object created by an example, directly from the list of examples. Let’s do that for the example pictureWithFacesAndNoForm
. For the object create by this example, the Picture view can still show the relative position of the three faces, even if there is no graphical representation.
Exemplifying landmarks within faces
Before moving on to the next section, let’s create a few more examples to capture other aspects of our model, like the landmarks that can be associated with Face
objects.
We begin by creating, for each face, an example returning the FaceLandmarks
object that holds the landmarks for that face.
Next, we add those landmarks to a face that is not associated with a picture. By inspecting this face object, we can already see the position of landmarks.
We can then take those faces and attach them to a picture that also still does not have a graphical representation. We can then browse the list of faces and look at the landmarks.
Finally, we attach a graphical representation to the example above. Now, we see how the actual landmarks map onto each face.
Using examples to specify documentation
To help other developers or users, we often end up creating documents describing scenarios that combine code with textual descriptions, images and diagrams. When it comes to embedding snippets of code, most of the times we do this by copy-pasting, or writing them directly in the document.
With examples, we already have the code for creating interesting objects. Instead of copy-pasting that code into a document, we could directly embed the example in the document. Tools for working with that document can then embed the code, or any other information, directly in the document.
GT Toolkit supports this kind of embedding using Pillar, a markdown-like markup language. For this example, we build a small document showing how to create Face
objects with landmarks and how to add them to a Picture
object that has a graphical representation.
Let’s start the document with the paragraph below. In it we have a snippet of text briefly describing how to create a Face
object . Then, after the text, we embed the example create by the method faceEinstein
.
To highlight faces within pictures we first need to create face objects. When creating a face we need to indicate the countour delimiting the face using the ==rectangle:== method.${example:name=CSPictureExamples>>#faceEinstein}$
We can add the text above as a comment to the class CSPictureExamples
containing the list of examples. If we then look at the comment within a tool, the code of the example is directly embedded in the document.
When embedding an example, we can further indicate that a view of the object created by that example should be embedded next to the code. To show that, we create next a short description about adding landmarks to a face object and embed the example faceEinsteinWithLandmarks
.
We can continue extending our document by adding textual descriptions, embedding other examples, and showing custom views.
In this case, with a few examples and custom views, we could easily describe a tutorial for adding faces to pictures, without having to embed code snippets or screenshots directly in the document. Instead, code and views are embedded starting from examples.
Documenting the steps for building domain objects
Last but not least, we can rely on examples to make explicit the steps for building an object structure. We can further compare the steps needed for creating different object structures.
An interesting object structure for this tutorial, is a picture and its faces. One such structure is created by the example pictureWithFacesAndForm
. As we relied on other examples to create each individual part of the picture, we can use those dependencies to document the steps needed to build thisPicture
object. We can then visualise these steps in different ways.
The two views above show that creating a picture with a graphical representation and three faces, consists in six steps: we start from an empty picture and three faces, continue by adding the three faces to the picture object, and finish by adding a graphical representation to the picture.
Another example that involves building a picture objects having faces and a graphical representation is pictureWithFacesWithLandmarksAndForm
. This builds a similar picture as the previous example, however, in this case, every Face
object has associated landmarks. We can again look at the steps needed to create this picture.
In this case, we see almost the same steps as before, with the difference that now, before adding a face to the picture, we attach to it an object containing the landmarks associated with that picture.
Between the two examples above, some steps are also shared. These consist in the example methods called from both examples. We can view these common steps explicitly by grouping the two examples and their dependencies using a dedicated ExampleGroup
, and inspecting it.
We observe that these two examples share the first four steps, for creating the initial picture and faces. After that, the first directly adds the three faces to the picture. The second adds the landmarks before adding faces to the picture. Afterwards, both attach a graphical representation to the picture.
Wrapping up
Tests are created during software development for various reasons. When written only to ensure correct behaviour, there might not always be a great incentive to do this, as they do require effort to develop and maintain.
Examples are not there just to verify that code works as expected. They serve as a foundation for helping us build stories that better explain the intricate details of our systems and domains to both ourselves, other developers and business stakeholders alike. This can justify the economic investment.
But, taking full advantage of examples, does not mean changing our test methods to just return objects instead of throwing them away. It means changing the way we approach testing, and most important of all, changing our development tools to fully embrace examples, instead of stoping when assertions are green.