Table of Contents
Keyword-driven testing (also called “table-driven testing” and “action-word testing”) is a testing methodology whereby tests are driven wholly by data. What makes keyword-driven testing different from data-driven testing is that in the latter we just read in data items, for example, to populate a GUI table, but in the former the data items aren't just data but the names of AUT-specific functions and their arguments which are then executed as the test runs.
The great advantage of keyword-driven testing is that the tests can be created purely as data tables in terms of high-level AUT actions such as “Add Item” or “Delete Item” that testers familiar with the AUT can relate to without having to know the more technical aspects under the hood.
The creation of keyword-driven tests involves two phases. First comes the one-off creation of some AUT-specific test script functions to interpret the data and of a generic “driver” function that reads test data from a data source and that executes the AUT-specific test script functions based on the data. The second is the creation of one or more test cases and corresponding data tables that are used to drive the tests.
In this section we will first look at how a tester goes about creating a test case and the corresponding test data, as well as the results this produces, and then we will look at the one-off work that must be done behind the scenes to make it all work.
All the examples shown in this section are in Squish's examples
directory
(SQUISHDIR/examples/qt/addressbook/suite_keyword_py
for the Python version,
SQUISHDIR/examples/qt/addressbook/suite_keyword_js
for the JavaScript version, and so on). The test case itself is called
tst_keyword_driven
.
Although we have used a Qt-based AUT the underlying GUI toolkit doesn't matter—using the ideas shown in this section you could create keyword-driven testing for any toolkit that Squish supports.
The test case needed for keyword-driven tests is always the same—and incredibly simple:
source(findFile("scripts", "driver.py")) def main(): drive("keywords.tsv")
source(findFile("scripts", "driver.js")); function main() { drive("keywords.tsv"); }
source( findFile( "scripts", "driver.pl" ) ); sub main { drive("keywords.tsv"); }
def main require findFile("scripts", "driver.rb") drive("keywords.tsv") end
source [findFile "scripts" "driver.tcl"] proc main {} { drive "keywords.tsv" }
First the test program loads in the generic “driver”
functionality and then it executes the drive()
function on
a test data file. The test data file specifes exactly what actions
should be taken, beginning with starting the AUT and ending with
terminating the AUT.
Here is what a typical (but rather small) data file might look like:
Keyword→Argument 1→Argument 2→Argument 3→Argument 4 startApplication→addressbook chooseMenuItem→File→New verifyRowCount→0 addAddress→Red→Herring→red.herring@froglogic.com→555 123 4567 addAddress→Blue→Cod→blue.cod@froglogic.com→555 098 7654 addAddress→Green→Pike→green.pike@froglogic.com→555 675 8493 verifyRowCount→3 removeAddress→green.pike@froglogic.com removeAddress→blue.cod@froglogic.com removeAddress→red.herring@froglogic.com verifyRowCount→0 terminate
(We have indicated tab-separators using the → character.) The first row contains the field names, the other rows contain the actions to be performed. In each action row the first column contains the name of a high-level AUT-specific function to execute, and the remaining columns contain any arguments that the function might require.
Here, the terminate
function requires no arguments and the
verifyRowCount
and removeAddress
functions
both require one argument (the former a row count to verify, and the
latter an email address to identify the row to be deleted). Similarly,
the chooseMenuItem
function always requires
two arguments (the name of the menu and the name of the menu's menu
item) and the addAddress
function requires four arguments
(forename, surname, email, phone).
Here are the results produced by a typical test run (with dates and most items elided):
Start tst_keyword_driven Log Drive: 'keywords.tsv' Log Execute: startApplication('addressbook') Log Execute: chooseMenuItem('File', 'New') Log Execute: verifyRowCount('0') Pass Verified Log Execute: addAddress('Red', 'Herring', 'red.herring@froglogic.com', '555 123 4567') Pass Verified Pass Comparison Pass Comparison Pass Comparison Pass Comparison ... Log Execute: verifyRowCount('3') Pass Verified Log Execute: removeAddress('green.pike@froglogic.com') Log Removed green.pike@froglogic.com Pass Verified ... Log Execute: verifyRowCount('0') Pass Verified Log Execute: terminate()
It should be obvious that we can easily add as many keyword actions as
we like—quite independently of the test case script which will
simply carry out whatever tests are specified in the data file. And, of
course, if additional actions were required—such as an
editAddress
function—there's no reason why that
couldn't be added as an AUT-specific function, and once added it could
then be used in the data file along with all the rest.
So, from the point of view of a tester who wants to do keyword-driven testing, the job is straightforward. However, for this simplicity to be achieved requires the writing of the AUT-specific functions that the test data needs to execute, along with the generic driver function. These are covered in the next two subsections.
Every keyword action specified in the test data must have a
corresponding function that takes the given arguments and performs the
given actions. There is no generic solution for this because each action
will be AUT-specific. Nonetheless, for completeness, we will show the
implementations of all the actions used in the test data shown above,
with one exception. The exception is the startApplication
function which is already built
into Squish so we don't need to implement it ourselves.
The following functions are all taken from the
action.py
(or action.js
and so
on), script.
def chooseMenuItem(menu, item): activateItem(waitForObjectItem(":Address Book_QMenuBar", menu)) activateItem(waitForObjectItem(":Address Book.%s_QMenu" % menu, item))
function chooseMenuItem(menu, item) { activateItem(waitForObjectItem(":Address Book_QMenuBar", menu)); activateItem(waitForObjectItem(":Address Book." + menu + "_QMenu", item)); }
sub chooseMenuItem { my ( $menu, $item ) = @_; activateItem( waitForObjectItem( ":Address Book_QMenuBar", $menu ) ); activateItem( waitForObjectItem( ":Address Book." . $menu . "_QMenu", $item ) ); }
def chooseMenuItem(menu, item) activateItem(waitForObjectItem(":Address Book_QMenuBar", menu)) activateItem(waitForObjectItem(":Address Book.#{menu}_QMenu", item)) end
proc chooseMenuItem {menu item} { invoke activateItem [waitForObjectItem ":Address Book_QMenuBar" $menu] invoke activateItem [waitForObjectItem ":Address Book.${menu}_QMenu" $item] }
This function activates the given menu and then the given menu item. We have used symbolic names which we got from the Object Map (Section 7.11).
Of course, the Object Map starts out empty, so the first thing we did—before creating any functions—was to record and play back a dummy test. In that test we did everything we planned to do in the keyword driven test (but with no repetitions). So we created a new file, added an address, removed an address, and quit. This populated the Object Map with most of the names we need.
The menu bar and menu options had more than one symbolic name referring
to them because as the AUT's state changed, its window title changed,
and Squish kept track of this by creating multiple symbolic names, each
with a different window
property value. This property's
value varied depending on the window's title, which itself varied. We
need to be able to access the menu bar and menu items regardless of the
AUT's state (and in particular, regardless of its window title).
To solve the problem we used the Object Map view (Section 8.2.10) to edit the Object Map. First we we
deleted the window
property from the ":Address
Book_QMenuBar"
item so that the menu bar could be found no matter
what the window's title. Then we deleted the window
property from the ":Address Book.File_QMenu"
item for the
same reason. Then we copied the ":Address Book.File_QMenu"
symbolic name and pasted it; we renamed the pasted version
":Address Book.Edit_QMenu"
and changed its
title
property's value from “File” to
“Edit”. We then deleted any menu items that contained
“Unnamed” in their symbolic names.
After editing the Object Map this function for invoking a menu and then one of its menu options works perfectly—irrespective of the AUT's window title.
import __builtin__ # Python 2 def verifyRowCount(rows): test.verify(__builtin__.int(rows) == getRowCount(), "row count")
function verifyRowCount(rows) { rows = parseInt(rows) test.verify(rows == getRowCount(), "row count"); }
sub verifyRowCount { my $rows = shift; test::verify( $rows eq getRowCount(), "row count" ); }
def verifyRowCount(rows) Test.verify(rows.to_i == getRowCount(), "row count") end
proc verifyRowCount {rows} { test compare $rows [getRowCount] "row count" }
This function is used to verify that the number of rows in the AUT are what we expect.
All of the keyword data is stored and retrieved as strings. We could, of course, use two columns for data items (type, value), or some other type-specifying scheme (such as type=value) items. But this pushes the burden of dealing with different types on the tester. So instead we accept everything in the form of strings and where we need other types, as here, we perform the conversion in the AUT-specific functions. (Except for Perl where we simply force a string comparison.)
For Python the conversion is slightly complicated by the fact that
Squish imports its own Python-specific int()
function
that's different from the built-in one. To work around this we import
Python's __builtin__
(Python 2) or builtins
(Python 3)
module and access Python's own int()
conversion function to turn
the rows
string into a number (See Python Notes (Section 6.14).)
def getRowCount(): tableWidget = waitForObject( ":Address Book - Unnamed.File_QTableWidget") return tableWidget.rowCount
function getRowCount() { tableWidget = waitForObject( ":Address Book - Unnamed.File_QTableWidget"); return tableWidget.rowCount; }
sub getRowCount { my $tableWidget = waitForObject(":Address Book - Unnamed.File_QTableWidget"); return $tableWidget->rowCount; }
def getRowCount tableWidget = waitForObject( ":Address Book - Unnamed.File_QTableWidget") tableWidget.rowCount end
proc getRowCount {} { set tableWidget [waitForObject \ ":Address Book - Unnamed.File_QTableWidget"] return [property get $tableWidget rowCount] }
This helper function retrieves a reference to the underlying toolkit's
table (in this case a QTableWidget
), and then returns the
value of its rowCount
property. The table's symbolic name
was copied from the Object Map after the dummy test run had populated
the Object Map.
def addAddress(forename, surname, email, phone): oldRowCount = getRowCount() chooseMenuItem("Edit", "Add...") type(waitForObject(":Forename:_LineEdit"), forename) type(waitForObject(":Surname:_LineEdit"), surname) type(waitForObject(":Email:_LineEdit"), email) type(waitForObject(":Phone:_LineEdit"), phone) clickButton(waitForObject(":Address Book - Add.OK_QPushButton")) newRowCount = getRowCount() test.verify(oldRowCount + 1 == newRowCount, "row count") row = oldRowCount # The first item is inserted at row 0; if row > 0: # subsequent ones at row rowCount - 1 row -= 1 checkTableRow(row, forename, surname, email, phone)
function addAddress(forename, surname, email, phone) { var oldRowCount = getRowCount(); chooseMenuItem("Edit", "Add..."); type(waitForObject(":Forename:_LineEdit"), forename); type(waitForObject(":Surname:_LineEdit"), surname); type(waitForObject(":Email:_LineEdit"), email); type(waitForObject(":Phone:_LineEdit"), phone); clickButton(waitForObject(":Address Book - Add.OK_QPushButton")); var newRowCount = getRowCount(); test.verify(oldRowCount + 1 == newRowCount, "row count"); var row = oldRowCount // The first item is inserted at row 0; if (row > 0) { // subsequent ones at row rowCount - 1 --row; } checkTableRow(row, forename, surname, email, phone); }
sub addAddress { my ( $forename, $surname, $email, $phone ) = @_; my $oldRowCount = getRowCount(); chooseMenuItem( "Edit", "Add..." ); type( waitForObject(":Forename:_LineEdit"), $forename ); type( waitForObject(":Surname:_LineEdit"), $surname ); type( waitForObject(":Email:_LineEdit"), $email ); type( waitForObject(":Phone:_LineEdit"), $phone ); clickButton( waitForObject(":Address Book - Add.OK_QPushButton") ); my $newRowCount = getRowCount(); test::verify( $oldRowCount + 1 == $newRowCount, "row count" ); my $row = $oldRowCount; # The first item is inserted at row 0 if ( $row > 0 ) { # subsequent ones at row rowCount - 1 --$row; } checkTableRow( $row, $forename, $surname, $email, $phone ); }
def addAddress(forename, surname, email, phone) oldRowCount = getRowCount() chooseMenuItem("Edit", "Add...") type(waitForObject(":Forename:_LineEdit"), forename) type(waitForObject(":Surname:_LineEdit"), surname) type(waitForObject(":Email:_LineEdit"), email) type(waitForObject(":Phone:_LineEdit"), phone) clickButton(waitForObject(":Address Book - Add.OK_QPushButton")) newRowCount = getRowCount() Test.verify(oldRowCount + 1 == newRowCount, "row count") row = oldRowCount # The first item is inserted at row 0; if row > 0 # subsequent ones at row rowCount - 1 row -= 1 end checkTableRow(row, forename, surname, email, phone) end
proc addAddress {forename surname email phone} { set oldRowCount [getRowCount] chooseMenuItem "Edit" "Add..." invoke type [waitForObject ":Forename:_LineEdit"] $forename invoke type [waitForObject ":Surname:_LineEdit"] $surname invoke type [waitForObject ":Email:_LineEdit"] $email invoke type [waitForObject ":Phone:_LineEdit"] $phone invoke clickButton [waitForObject ":Address Book - Add.OK_QPushButton"] set newRowCount [getRowCount] test compare [expr {$oldRowCount + 1}] $newRowCount "row count" set row $oldRowCount if {$row > 0} { set row [expr {$row - 1}] } checkTableRow $row $forename $surname $email $phone }
This function adds an address to the
addressbook application. It begins by
retrieving the row count, then it uses the custom
chooseMenuItem
function to invoke the Edit|Add... menu
option to pop up the Add dialog, and then it types in each piece of
information into the appropriate line editor. Next, it clicks the
dialog's OK button. Once the dialog has been accepted the row count is
retrieved once more and we verify that it is now one more than before.
We also check that every item of data was correctly entered into the
table using a custom checkTableRow
function.
One slightly tricky part at the end of the function is that the row we
ask the checkTableRow
function to verify must be computed
with care. If there are no addresses (which is the initial case), the
new address will be inserted at row 0 (the first row). But each
subsequent address is inserted before the current
row (and the current row is always the one that was inserted before). So
the second address is also inserted at row 0, the third address at row
1, and so on. This is simply a behavioral quirk of our
addressbook application that we must account
for in our test.
FORENAME, SURNAME, EMAIL, PHONE = list(range(4)) def checkTableRow(row, forename, surname, email, phone): tableWidget = waitForObject( ":Address Book - Unnamed.File_QTableWidget") test.compare(forename, tableWidget.item(row, FORENAME).text(), "forename") test.compare(surname, tableWidget.item(row, SURNAME).text(), "surname") test.compare(email, tableWidget.item(row, EMAIL).text(), "email") test.compare(phone, tableWidget.item(row, PHONE).text(), "phone")
var FORENAME = 0; var SURNAME = 1; var EMAIL = 2; var PHONE = 3; function checkTableRow(row, forename, surname, email, phone) { tableWidget = waitForObject( ":Address Book - Unnamed.File_QTableWidget"); test.compare(forename, tableWidget.item(row, FORENAME).text(), "forename"); test.compare(surname, tableWidget.item(row, SURNAME).text(), "surname"); test.compare(email, tableWidget.item(row, EMAIL).text(), "email"); test.compare(phone, tableWidget.item(row, PHONE).text(), "phone"); }
my $FORENAME = 0; my $SURNAME = 1; my $EMAIL = 2; my $PHONE = 3; sub checkTableRow { my ( $row, $forename, $surname, $email, $phone ) = @_; my $tableWidget = waitForObject(":Address Book - Unnamed.File_QTableWidget"); test::compare( $forename, $tableWidget->item( $row, $FORENAME )->text(), "forename" ); test::compare( $surname, $tableWidget->item( $row, $SURNAME )->text(), "surname" ); test::compare( $email, $tableWidget->item( $row, $EMAIL )->text(), "email" ); test::compare( $phone, $tableWidget->item( $row, $PHONE )->text(), "phone" ); }
FORENAME = 0 SURNAME = 1 EMAIL = 2 PHONE = 3 def checkTableRow(row, forename, surname, email, phone) tableWidget = waitForObject( ":Address Book - Unnamed.File_QTableWidget") Test.compare(forename, tableWidget.item(row, FORENAME).text(), "forename") Test.compare(surname, tableWidget.item(row, SURNAME).text(), "surname") Test.compare(email, tableWidget.item(row, EMAIL).text(), "email") Test.compare(phone, tableWidget.item(row, PHONE).text(), "phone") end
proc checkTableRow {row forename surname email phone} { set FORENAME 0 set SURNAME 1 set EMAIL 2 set PHONE 3 set tableWidget [waitForObject \ ":Address Book - Unnamed.File_QTableWidget"] set text [invoke [invoke $tableWidget item $row $FORENAME] text] test compare $forename $text "forename" set text [invoke [invoke $tableWidget item $row $SURNAME] text] test compare $surname $text "surname" set text [invoke [invoke $tableWidget item $row $EMAIL] text] test compare $email $text "email" set text [invoke [invoke $tableWidget item $row $PHONE] text] test compare $phone $text "phone" }
This function compares each table cell that has just been edited with the text that was typed into it to verify that they are the same.
def removeAddress(email): tableWidget = waitForObject( ":Address Book - Unnamed.File_QTableWidget") oldRowCount = getRowCount() for row in range(oldRowCount): if tableWidget.item(row, EMAIL).text() == email: tableWidget.setCurrentCell(row, EMAIL) chooseMenuItem("Edit", "Remove...") clickButton(waitForObject( ":Address Book - Delete.Yes_QPushButton")) test.log("Removed %s" % email) break newRowCount = getRowCount() test.verify(oldRowCount - 1 == newRowCount, "row count")
function removeAddress(email) { tableWidget = waitForObject( ":Address Book - Unnamed.File_QTableWidget") var oldRowCount = getRowCount(); for (var row = 0; row < oldRowCount; ++row) { if (tableWidget.item(row, EMAIL).text() == email) { tableWidget.setCurrentCell(row, EMAIL); chooseMenuItem("Edit", "Remove..."); clickButton(waitForObject( ":Address Book - Delete.Yes_QPushButton")) test.log("Removed " + email); break; } } var newRowCount = getRowCount(); test.verify(oldRowCount - 1 == newRowCount, "row count"); }
sub removeAddress { my $email = shift; my $tableWidget = waitForObject(":Address Book - Unnamed.File_QTableWidget"); my $oldRowCount = getRowCount(); for ( my $row = 0 ; $row < $oldRowCount ; ++$row ) { if ( $tableWidget->item( $row, $EMAIL )->text() eq $email ) { $tableWidget->setCurrentCell( $row, $EMAIL ); chooseMenuItem( "Edit", "Remove..." ); clickButton( waitForObject(":Address Book - Delete.Yes_QPushButton") ); test::log("Removed $email"); last; } } my $newRowCount = getRowCount(); test::verify( $oldRowCount - 1 == $newRowCount, "row count" ); }
def removeAddress(email) tableWidget = waitForObject( ":Address Book - Unnamed.File_QTableWidget") oldRowCount = getRowCount() for row in 0...oldRowCount if tableWidget.item(row, EMAIL).text() == email tableWidget.setCurrentCell(row, EMAIL) chooseMenuItem("Edit", "Remove...") clickButton(waitForObject( ":Address Book - Delete.Yes_QPushButton")) Test.log("Removed #{email}") break end end newRowCount = getRowCount() Test.verify(oldRowCount - 1 == newRowCount, "row count") end
proc removeAddress {email} { set EMAIL 2 set tableWidget [waitForObject \ ":Address Book - Unnamed.File_QTableWidget"] set oldRowCount [getRowCount] for {set row 0} {$row < $oldRowCount} {incr row} { set text [toString [invoke [invoke $tableWidget item $row $EMAIL] text]] if {[string equal $text $email]} { invoke $tableWidget setCurrentCell $row $EMAIL chooseMenuItem "Edit" "Remove..." invoke clickButton [waitForObject \ ":Address Book - Delete.Yes_QPushButton"] test log "Removed $email" break } } set newRowCount [getRowCount] test compare [expr {$oldRowCount - 1}] $newRowCount "row count" }
To make it easier for testers who are populating the keyword data we
have provided a removeAddress
function that takes an email
address to identify which row to delete. (This is based on the
reasonable assumption that every email address in the address book is
unique.)
The function begins by retrieving a reference to the AUT's table and also the current row count. It then iterates over every row until it finds one with a matching email address. Once it has a match it makes the corresponding cell the current one and invokes the AUT's Edit|Remove... menu option to delete it. This menu option results in a Yes/No confirmation dialog popping up—the function clicks the dialog's Yes button. Once the deletion is done the loop is broken out of and we verify that the row count is one less than it was before.
def terminate(): sendEvent("QCloseEvent", waitForObject(":Address Book_MainWindow")) clickButton(waitForObject(":Address Book.No_QPushButton"))
function terminate() { sendEvent("QCloseEvent", waitForObject(":Address Book_MainWindow")); clickButton(waitForObject(":Address Book.No_QPushButton")); }
sub terminate { sendEvent( "QCloseEvent", waitForObject(":Address Book_MainWindow") ); clickButton( waitForObject(":Address Book.No_QPushButton") ); }
def terminate sendEvent("QCloseEvent", waitForObject(":Address Book_MainWindow")) clickButton(waitForObject(":Address Book.No_QPushButton")) end
proc terminate {} { sendEvent QCloseEvent [waitForObject ":Address Book_MainWindow"] invoke clickButton [waitForObject ":Address Book.No_QPushButton"] }
To terminate the AUT we must first invoke the File|Quit menu option and then click No on the “save changes” dialog that pops up so that we cleanly exit without saving anything.
This completes the review of the AUT-specific functions. All the
functions except for the getRowCount
helper function are
used in the keyword data. The only missing piece is the driver function
that will take the keyword data and use it to call the AUT-specific
functions: we will cover this in the next subsection.
The driver.py
file (or
driver.js
, and so on), provides a single function,
driver
, that accepts a keywords data file as its sole
argument and executes the commands specified in that data.
source(findFile("scripts", "actions.py")) def drive(datafile): test.log("Drive: '%s'" % datafile) for _, record in enumerate(testData.dataset(datafile)): command = testData.field(record, "Keyword") + "(" comma = "" for i in range(1, 5): arg = testData.field(record, "Argument %d" % i) if arg: command += "%s%r" % (comma, arg) comma = ", " else: break command += ")" test.log("Execute: %s" % command) eval(command)
source(findFile("scripts", "actions.js")); function drive(datafile) { test.log("Drive: '" + datafile + "'"); var records = testData.dataset(datafile); for (var row = 0; row < records.length; ++row) { var command = testData.field(records[row], "Keyword") + "("; var comma = ""; for (var i = 1; i <= 4; ++i) { var arg = testData.field(records[row], "Argument " + i); if (arg != "") { command += comma + "'" + arg + "'"; comma = ", "; } else { break; } } command += ")"; test.log("Execute: " + command); eval(command); } }
source( findFile( "scripts", "actions.pl" ) ); sub drive { my $datafile = shift; test::log("Drive: '$datafile'"); my @records = testData::dataset($datafile); for ( my $row = 0 ; $row < scalar(@records) ; ++$row ) { my $command = testData::field( $records[$row], "Keyword" ) . "("; my $comma = ""; for ( my $i = 1 ; $i <= 4 ; ++$i ) { my $arg = testData::field( $records[$row], "Argument $i" ); if ( $arg ne "" ) { $command .= "$comma\"$arg\""; $comma = ", "; } else { last; } } $command .= ");"; test::log("Execute: $command"); eval $command; } }
def drive(datafile) require findFile("scripts", "actions.rb") Test.log("Drive: '#{datafile}'") TestData.dataset(datafile).each_with_index do |record, row| command = TestData.field(record, "Keyword") + "(" comma = "" for i in 1...5 arg = TestData.field(record, "Argument #{i}") if arg and arg != "" command += "#{comma}'#{arg}'" comma = ", " else break end end command += ")" Test.log("Execute: #{command}") eval command end end
source [findFile "scripts" "actions.tcl"] proc drive {datafile} { test log "Drive: '$datafile'" set data [testData dataset $datafile] for {set row 0} {$row < [llength $data]} {incr row} { set command [testData field [lindex $data $row] "Keyword"] for {set i 1} {$i <= 4} {incr i} { set arg [testData field [lindex $data $row] "Argument $i"] if {$arg != ""} { set command "${command} \"${arg}\"" } else { break } } test log "Execute: $command" eval $command } }
The first thing that must be done is to access the AUT-specific actions
by importing the actions.py
file (or
actions.js
and so on).
The drive
function iterates over every row in the test data
(Squish's testData.dataset
function
automatically skips over the first row that has the field names). For
each row the function retrieves the keyword (i.e., the name of the
AUT-specific function to execute), and then the arguments. In this case
we have limited the keyword data to have up to four arguments but it is
easy to allow more.
For each keyword data record we create a command string consisting of the AUT-specific function to call and any arguments that have been given. Once the command has been prepared we log what is about to be executed and then evaluate (i.e., execute) the command.
This completes the under-the-hood functionality required to support
keyword-driven testing in Squish. None of the work needed is
particularly difficult. The driver function need be written only once
since it can be used with any AUTs no matter what GUI toolkits they use
(providing only that the AUT-specific functions are in a script called
actions.py
or actions.js
and
so on, as appropriate for the scripting language).
The AUT-specific functionality need be written only once per AUT,
although some functions might be reusable across AUTs that use the same
GUI toolkit.