Acceptance testing with
FitNesse and PyFIT
Author: Grig Gheorghiu
Last Revision: 12/14/2004
What follows is a step-by-step tutorial on using PyFIT within the FitNesse framework. PyFIT, written by John Roth, is the Python port of Ward Cunningham’s FIT framework
I installed FitNesse locally, on a Windows box, but the examples should work just as well in any environment supported by FitNesse.
run.bat
-p 8080
!define
COMMAND_PATTERN {python "%m" %p}
!define
TEST_RUNNER {C:\Python23\PyFIT-0.6a1\fit\FitServer.py}
|eg.Division|
|numerator|denominator|quotient?|
|10|5|2|
|10|2|5.001|
the cell in the first row tells FitNesse to run a fixture called eg.Division
the cells in second row are the names of variables to be set or get by FitNesse
a ? mark next to a variable name means that FitNesse will retrieve the value for that variable and will compare it agains the values entered by the user
the next 2 rows are examples of input (10 and 5 in the second row, 10 and 2 in the third row) and expected output (2 in the second row and 5.001 in the third row)
Assertions: 1 right, 1 wrong, 0 ignored, 0 exceptions
from fit.ColumnFixture import ColumnFixture
class Division(ColumnFixture):
_typeDict={"numerator": "Float",
"denominator": "Float",
"quotient": "Float",
"quotient.charBounds": "99",
}
numerator = 0.0
denominator = 0.0
def quotient(self):
return self.numerator / self.denominator
"FIT, as distributed, requires the use of a Type Adapter to convert the text format used in the tables to and from the actual data type needed by the various fields, methods and properties in the fixture. This practice came from the Java version, where manifest typing makes it easy to find the expected data type by reflection. Since Python does not have manifest typing, there is no way that the reflection capability can determine the proper type. Type information must be provided another way. Since types need to be declared separately, TypeAdapter contains a more general metadata mechanism. This consists of a dictionary named _typeDict that must be located in the class whose fields, methods or properties are to be referenced. It's also possible to pass a metadata dictionary to the type adapter factory function; this is useful for unusual requirements."
!define
COMMAND_PATTERN {python "%m" %p}
!define
TEST_RUNNER {C:\Python23\PyFIT-0.6a1\fit\FitServer.py}
!path
C:\eclipse\workspace\blogger
!2
''Blog Management acceptance test suite''
|
^DeleteAllEntries|''Delete
all blog entries''|
!3 We
test deleting all entries from the blog
We
delete all blog entries and we verify that we have 0 entries.
!|BloggerFixtures.DeleteAllEntries|
|num_entries?| |0|
Fixture 'BloggerFixtures.DeleteAllEntries' not found
Also, if you click on the Output Captured link in the right upper corner, you should see this (on a single line):
template: 'Fixture '%s' not found' args: '('FixtureNotFound', u'BloggerFixtures.DeleteAllEntries')'
Of course, we did not write the DeleteAllEntries fixture yet. Let's first create a directory called BloggerFixtures under C:\eclipse\workspace\blogger (if you create this directory somewhere else, you need to replace the !path variable in the BlogMgmtSuite page with the parent directory of BloggerFixtures). We also need to create an empty filed called __init__.py in the BloggerFixture directory, otherwise FitServer.py will not consider it a package and thus will not know how to interpret BloggerFixtures.DeleteAllEntries
Now let's write the actual DeleteAllEntries fixture. It resembles the Division fixture discussed previously as an example. Here it is, in its entirety:
from fit.ColumnFixture import ColumnFixture
import sys
blogger_path = "C:\\eclipse\\workspace\\blogger"
sys.path.append(blogger_path)
import Blogger
class DeleteAllEntries(ColumnFixture):
_typeDict={"num_entries": "Int",
}
blogger = Blogger.get_blog()
def num_entries(self):
return self.blogger.get_num_entries()
def execute(self):
self.blogger.delete_all_entries()
Assertions: 1 right, 0 wrong, 0
ignored, 0 exceptions
We wrote our first FitNesse fixture. Time to celebrate by going back to the BlogMgmtSuite page and then clicking on the Suite button. This will run all the tests found on the page -- in our case only DeleteAllEntries. You should see the following summary colored green at the top of the page:
Test
Pages:
1 right, 0 wrong, 0 ignored, 0 exceptions Assertions: 1 right,
0 wrong, 0 ignored, 0 exceptions
The summary is followed by individual test results with links to the tests. In our case, we have: 1 right, 0 wrong, 0 ignored, 0 exceptions DeleteAllEntries
We made BlogMgmtSuite a test suite (as opposed to a test page) so that we can easily add more test pages to it. Let's add another acceptance test which will test posting and then deleting a blog entry. Edit the BlogMgmtSuite page, add the following line and save:
|^PostDelete1Entry|''Post
single blog entry''|
Now click on the ? link next to PostDelete1Entry, enter the following text, save the page and don't forget to make it a Test page by clicking the Test checkbox in its Properties:
!3
We test posting a single new entry to the blog
First
we delete all entries from the blog and we verify that we have 0 entries.
!|BloggerFixtures.DeleteAllEntries|
|num_entries?| |0|
Then
we post the new entry and we verify that we have 1 entry.
!|BloggerFixtures.PostNewEntry|
|title|content|valid?|num_entries?|
|BloggerFixtures.PostSingleEntry
Title|BloggerFixtures.PostSingleEntry Content|true|1|
We
verify that the entry has the title and the content we indicated.
!|BloggerFixtures.GetEntryTitleContent|
|entry_index|title?|content?|
|1|BloggerFixtures.PostSingleEntry
Title|BloggerFixtures.PostSingleEntry Content|
We
delete the entry and we verify that we have no entries left.
!|BloggerFixtures.DeleteEntry|1|
|valid?|num_entries?|
|true|0|
Note how convenient it is to write down an acceptance test in FitNesse. We simply explain what we want to do, then we put together tables with inputs and desired outputs. FitNesse will ignore anything that is not part of the table, and will invoke the fixtures defined in the tables
The page contains many fixtures that we haven't defined yet. After calling DeleteAllEntries, we call PostNewEntry, which is another ColumnFixture with 2 member variables (title and content) and 2 methods (valid and num_entries). We need to create another Python module in the BloggerFixtures directory and call it PostNewEntry.py. Here is my version of it:
from fit.ColumnFixture import ColumnFixture
import sys
blogger_path = "C:\\eclipse\\workspace\\blogger"
sys.path.append(blogger_path)
import Blogger
class PostNewEntry(ColumnFixture):
_typeDict={"title": "String",
"content": "String",
"num_entries": "Int",
"valid": "Boolean"
}
title = ""
content = ""
blogger = Blogger.get_blog()
def num_entries(self):
return self.blogger.get_num_entries()
def valid(self):
return self.blogger.post_new_entry(self.title, self.content)
from fit.ColumnFixture import ColumnFixture
import sys
blogger_path = "C:\\eclipse\\workspace\\blogger"
sys.path.append(blogger_path)
import Blogger
class DeleteEntry(ColumnFixture):
_typeDict={"num_entries": "Int",
"valid": "Boolean"
}
blogger = Blogger.get_blog()
def num_entries(self):
return self.blogger.get_num_entries()
def valid(self):
entry_index = int(self.getArgs()[0])
return self.blogger.delete_nth_entry(entry_index)
An interesting thing to note in the DeleteEntry fixture is that it gets a parameter passed via the FitNesse table cell next to the fixture name:
!|BloggerFixtures.DeleteEntry|1|
The parameter is available in the DeleteEntry.py class via the self.getArgs() list, which in this case contains only 1 element
Here is GetEntryTitleContent.py:
from fit.ColumnFixture import ColumnFixture
import sys
blogger_path = "C:\\eclipse\\workspace\\blogger"
sys.path.append(blogger_path)
import Blogger
class GetEntryTitleContent(ColumnFixture):
_typeDict={"entry_index": "Int",
"title": "String",
"content": "String",
}
entry_index = 0
blogger = Blogger.get_blog()
def title(self):
return self.blogger.get_nth_entry_title(self.entry_index)
def content(self):
return self.blogger.get_nth_entry_content_strip_html(self.entry_index)
Test
Pages:
2 right, 0 wrong, 0 ignored, 0 exceptions Assertions: 9 right,
0 wrong, 0 ignored, 0 exceptions
1 right, 0 wrong, 0 ignored, 0 exceptions DeleteAllEntries
8 right, 0 wrong, 0 ignored, 0 exceptions PostDelete1Entry
Concluding thoughts
1. I was able to see how FitNesse acceptance tests exercise the application in ways that unit tests do not.
In a typical unit test scenario, a single application object (for example a Blogger object) is instantiated in the setUp method and then used throughout the test case class methods
In a typical FitNesse acceptance test, there are several fixture objects created during the execution of a test page, each object using potentially different instances of the application object. This can result in inconsistencies between the states of these objects, and thus in seemingly mysterious failures. Case in point: I started getting failures after posting three entries and deleting them one by one via the PostDelete3Entries test page. This page is using 4 different fixtures: DeleteAllEntries, PostNewEntry, GetEntryTitleContent and DeleteEntry. Each of these fixtures appears in several tables, but the PyFIT framework only instantiates one object per fixture, so for example the same GetEntryTitleContent object is used in every row of every table corresponding to the GetEntryTitleContent fixture. When I first wrote the fixtures, each fixture object had its own copy of a Blogger object, so there was a disconnect between the number and order of entries reported by each fixture. For example, when an entry was deleted via DeleteEntry, the fact was not reflected in the GetEntryTitleContent object.
Here is an example of how DeleteEntry looked initially:
class DeleteEntry(ColumnFixture):
_typeDict={"num_entries": "Int",
"valid": "Boolean"
}
blog_params = Blogger.BlogParams()
blogger = Blogger.Blogger(blog_params)
def num_entries(self):
return self.blogger.get_num_entries()
etc...
I rewrote the fixture so that now it looks like this:
class DeleteEntry(ColumnFixture):
_typeDict={"num_entries": "Int",
"valid": "Boolean"
}
blogger = Blogger.get_blog()
def num_entries(self):
return self.blogger.get_num_entries()
etc...
Note the call to Blogger.get_blog(), which returns a global variable at the Blogger.py module level, thus guaranteeing that all fixture objects will share a single instance of a Blogger object. The FitNesse documentation recommends using static instances of Singleton objects for sharing an object among tables on the same page. I have not done so yet and instead I took the easy way out by resorting to a shared global variable. I will experiment with implementing the Singleton pattern.
2. I had problems with the HTTP connection to blogger.com, hence many times both the unit tests and the FitNesse acceptance tests failed. I intend to isolate this type of failures by using Mock Objects (for example the pMock Python module) in order to simulate posting and deleting blog entries without actually using the Blogger API.
3. FitNesse advocates the use of SetUp and TearDown pages that are automatically invoked at the beginning and the end of a test suite run. I have not used them so far, but I intend to use them, for example for instantiating a Singleton object in the SetUp page. I realize that all this is just scratching the surface of what FitNesse can do, and I intend to further explore its functionality. My next goal is to experiment with RowFixture and ActionFixture tables. The GetEntryTitleContent fixture in particular is a query, so it's especially suitable for being expressed as a RowFixture. I expressed it as a ColumnFixture mainly because it seemed pretty hard to write RowFixture-derived classes in PyFIT, but I intend to remedy this.