This project is now available on GitHub.

Before reading this tutorial (if you haven’t already), please took at look at my last two tutorials (Performance Testing using The Grinder and Anatomy of a Grinder test-script). In this tutorial I’ll talk about easily writing Grinder test-scripts using a framework I designed. As a disclaimer, I’d like to point out that I’m not a Python programmer and therefore certain things may not be very python-esque. If that’s the case, I apologize. My personal opinion is that this framework is especially useful (of course, since I wrote it ;)) for web applications where you have a already have a lot of test data. In that case, you can simply record all your discrete tasks once and then construct different scenarios with them. But if you feel differently and have some constructive criticism, I do look forward to hearing from you! Also, if you’d like to try out the framework I’ve got a tarball and a zip file available for download on the very last page.

The motivation for a framework

Typically during performance-testing, you want to test different kinds of scenarios. These scenarios are made up of discrete tasks (also known as transactions), for example, consider a scenario where a user logs in, searches for a person, and then logs out. In this scenario, you have three separate tasks: logging in, searching for a person, and logging out. Using Grinder’s TCPProxy tool, you can easily record this scenario and you can also parameterize it. What happens when you want to record another scenario? Say, one that involves logging in, searching for a person, adding a new person, and logging out? Sure, you can go ahead and record that, and even parameterize it. But consider the fact that the new scenario is a super-set of the old scenario. It has three tasks in common with the first scenario. What if there was a way to create new scenarios from scratch, not by recording, but by using previously recorded tasks? This way you would only have to record information once, and after that, you can reuse it. To do this, we need to first understand how to identify tasks within a recorded Grinder test-script. Using this as our foundation, we can figure out how to pull out the tasks into discrete units, which we can later reuse.

The Relationship between Requests and Tasks

In the previous tutorial, I went over the anatomy of a recorded Grinder test-script. There, we saw that the script defines a bunch of requests, and then has methods that correspond to each recorded page. In each method, the script executes a bunch of requests. Initially you might think that we would simply pull out these individual methods. However, that is not the case. A single task can involve more than one recorded page. For example, the act of logging into the app involves accessing the login page, then logging in, and then hitting whatever page the login process drops you into. This involves (at the very least) three pages. In fact, the relationship between requests, pages, and tasks looks something like this:

The relation between requests and tasks

Here, you can see that a set of unique requests belong to a recorded page, and then a set of recorded pages belong to a unique task. Finally, a set of tasks belong to a scenario. What we want to do is pull out the individual tasks so that we can reuse them to create different scenarios. To do this, we will still be using Grinder’s TCPProxy tool (at least to record the scenario), but our resulting script will not look like the typical Grinder test-script. It will, instead, conform to the testing framework.

A high-level view of the framework

To understand the framework, first let’s figure out what it is that we want to do exactly. To create a scenario, our framework should let us do the following things:

  1. Define a scenario

  2. Add tasks to the scenario

  3. Execute the scenario

How about looking at what we want to do from a programming perspective?

  1. Create a scenario object

  2. Add the tasks we want to the scenario object

  3. Execute the scenario

Of course, this is still a rather simple view. We actually want to do a bit more than that. We also want to parameterize our tasks. So, the list of things we want to do actually looks like this:

  1. Create a scenario object

  2. Create an instance of a task we want

  3. Parameterize the task

  4. Add the task to the scenario object

  5. Repeat steps 2-4 as many times as necessary

  6. Execute the scenario

Now that we know what we want to do, let’s take a look at the actual code behind the framework.

The Task class

All tasks that you record and create will extend the Task class. The Task class by itself is pretty simple, and not very useful. It looks like this:

Task.py:

# Base class for all Tasks.

class Task:

      numberOfTasks = 0;

      def __init__(self):
          """Initialize properties of class"""
          self.urlDict = {}
          self.parameterizingMethods = {}
          Task.numberOfTasks += 1

      def setUrlDict(self, urlDict):
          """Setter for urlDict property"""
          self.urlDict = urlDict

      def getUrlDict(self, urlDict):
          """Getter for urlDict property"""
          return self.urlDict

      def setUrl(self, key, value):
          """Sets value to 'value' of particular url in URL dict identified by key 'key'"""
          self.urlDict[key] = value

      def getUrl(self, key):
          """Returns value of particular url in URL dict identified by key 'key'"""
          return self.urlDict[key]

      def callParameterizingMethodFor(self, key):
          """Calls parameterizing method associated with page identified by key 'key'"""
          if(self.parameterizingMethods.has_key(key)):
             self.parameterizingMethods[key]()

      def setParameterizingMethodFor(self, key, value):
          """Sets parameterizing method associated with page identified by key 'key' to method in 'value'"""
          self.parameterizingMethods[key] = value

      def getParameterizingMethodFor(self, key):
          """Returns the parameterizing method associated with page identified by key 'key'"""
          return self.parameterizingMethods[key]

      def instrumentMethod(self, test, method_name):
          """Instrument a method with the given Test."""
          unadorned = getattr(self.__class__, method_name)
          import new
          method = new.instancemethod(test.wrap(unadorned), None, self.__class__)
          setattr(self.__class__, method_name, method)

Although not useful by itself, the Task class does define some important methods and properties:

Class Variables

In Python, properties defined at the top of the class are called Class Variables. They are different from Instance Variables in the sense that they are shared between all instances of a class. The numberOfTasks property holds the number of Task instances that have been instantiated. The reason we need to maintain this property will become clearer later.

Constructor init is the constructor for the Task class. Here we initialize a few instance properties. The first property is the urlDict property, which maintains a dictionary of unique URLs. If you’ll remember, when I talked about the structure of a test-script, I mentioned that the recorder keeps track of unique URLs that it encountered during the recording process. It then used the URLs and headers to create request objects. In a typical recorded-script, the URLs are simply defined as global variables within the script. Here, they are maintained as key-value pairs with the urlDict dictionary.

The next property is the parameterizingMethods property. This property is pretty important since it is what allows us to parameterize requests. I won’t go into too much detail; for now just realize that it keeps tracks of methods we use to parameterize our requests.

At the end of our constructor, we increment the value of numberOfTasks by one, because as I mentioned before, this property keeps track of the number of instances of this particular class, that have been instantiated.

Accessor methods

The Task class has three self-explanatory accessor-methods which are getUrlDict, getUrl, and getParameterizingMethodFor. The first method returns the entire urlDict dictionary, while the second returns a particular URL (identified by a supplied key) from the urlDict dictionary. The third method returns the parameterizing method for a particular (supplied) key.

Mutator methods

The Task class also has three self-explanatory mutator methods which are setUrlDict, setUrl, and setParameterizingMethodFor. The first method sets the urlDict property to the one supplied, while the second one sets the value of a particular URL within the urlDict dictionary (identified by a supplied key) to the supplied value. The setParameterizingMethodFor method sets the parameterizing method for a particular (supplied) key.

Utility methods

The Task class has two utility methods. The first one, callParameterizingMethodFor, calls the parameterizing method identified by the supplied key. The second method, instrumentMethod is quite similar to the instrumentMethod method in a typical recorded-script. The only difference is that there is no default argument, because we don’t need one in this case (instrumentMethod is a class method now, as opposed to a global method).

Now that we’ve gone over the base class, let’s take a look at a class that has been derived from the Task class so that all of this makes a bit more sense. While going through it, compare it to the structure of a typical recorded test-script. You will notice a few similarities. *Note: *In the following code you’ll notice that the framework is called torqueo. I called it that because the word means "to twist, curl, rack, torture, torment, distort, or test" in Latin. I think that accurately describes what we’re trying to do in performance testing. Yes, I’m a dork.

LoginTask LoginTask is a relatively simple task. All it does is log into the application. It looks like this:

LoginTask.py

# Converted from XML to Jython by xmlToJython.pl on 19:33:46, Thu Jun 25, 2009
# The Grinder 3.2
# HTTP script recorded by TCPProxy at 2009-06-25T14:17:56.771-07:00

from torqueo.test.framework.Task import Task
from HTTPClient import NVPair
from net.grinder.plugin.http import HTTPPluginControl, HTTPRequest
from net.grinder.script import Test
from net.grinder.script.Grinder import grinder

class LoginTask(Task):

      connectionDefaults = HTTPPluginControl.getConnectionDefaults()
      httpUtilities = HTTPPluginControl.getHTTPUtilities()

      # To use a proxy server, uncomment the next line and set the host and port.
      # connectionDefaults.setProxyServer("localhost", 8001)

      # These definitions at the top of the class are Class Variables (as opposed to
      # Instance Variables) and are shared between all instances of this class.

      connectionDefaults.defaultHeaders = \
          (
            NVPair('User-Agent', 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.0.11) Gecko/2009060308 Ubuntu/9.04 (jaunty) Firefox/3.0.11'),
            NVPair('Accept-Encoding', 'gzip,deflate'),
            NVPair('Accept-Language', 'en-us,en;q=0.5'),
            NVPair('Accept-Charset', 'ISO-8859-1,utf-8;q=0.7,*;q=0.7'),
          )

      headers0 = \
          (
            NVPair('Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'),
          )

      headers1 = \
          (
            NVPair('Accept', 'image/png,image/*;q=0.8,*/*;q=0.5'),
            NVPair('Referer', 'https://local.sitetotest.com:8443/'),
          )

      headers2 = \
          (
            NVPair('Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'),
            NVPair('Referer', 'https://local.sitetotest.com:8443/'),
          )

      headers3 = \
          (
            NVPair('Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'),
            NVPair('Referer', 'https://local.sitetotest.com:8443/Admin/home.jsp'),
          )

      headers4 = \
          (
            NVPair('Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'),
            NVPair('Referer', 'https://local.sitetotest.com:8443/Admin/home.jsp'),
            NVPair('Cache-Control', 'no-cache'),
          )

      headers5 = \
          (
            NVPair('Accept', 'text/css,*/*;q=0.1'),
            NVPair('Referer', 'https://local.sitetotest.com:8443/Admin/home.jsp'),
          )

      def __init__(self):
          """Initialize properties of class"""
          Task.__init__(self)
          self.description = "Log into the app"
          self.urlDict = {}
          self.taskId = Task.numberOfTasks

      def initializeTask(self):
          """Initializes Instance Variables for this class. This method will be called by the Scenario object that this task belongs to."""
          if(not self.urlDict.has_key("url0")):
             raise Exception(self.__class__.__name__ + ".urlDict is missing values for one or more of the following keys: [url0]. Please define them in the constructor for the parent Scenario.")
          else:
             self.request101 = HTTPRequest(url=self.urlDict["url0"], headers=LoginTask.headers0)
             self.request101 = Test(int(str(self.taskId) + str(101)), "Log into the app: GET /").wrap(self.request101)

             self.request102 = HTTPRequest(url=self.urlDict["url0"], headers=LoginTask.headers1)
             self.request102 = Test(int(str(self.taskId) + str(102)), "Log into the app: GET torqueo-crm.gif").wrap(self.request102)

             self.request103 = HTTPRequest(url=self.urlDict["url0"], headers=LoginTask.headers2)
             self.request103 = Test(int(str(self.taskId) + str(103)), "Log into the app: GET defaultLogin.jsp").wrap(self.request103)

             self.request104 = HTTPRequest(url=self.urlDict["url0"], headers=LoginTask.headers2)
             self.request104 = Test(int(str(self.taskId) + str(104)), "Log into the app: GET index.jsp").wrap(self.request104)

             self.request201 = HTTPRequest(url=self.urlDict["url0"], headers=LoginTask.headers2)
             self.request201 = Test(int(str(self.taskId) + str(201)), "Log into the app: POST processLogin.jsp").wrap(self.request201)

             self.request202 = HTTPRequest(url=self.urlDict["url0"], headers=LoginTask.headers2)
             self.request202 = Test(int(str(self.taskId) + str(202)), "Log into the app: GET home.jsp").wrap(self.request202)

             self.request301 = HTTPRequest(url=self.urlDict["url0"], headers=LoginTask.headers3)
             self.request301 = Test(int(str(self.taskId) + str(301)), "Log into the app: GET popUpTask.jsp").wrap(self.request301)

             self.request401 = HTTPRequest(url=self.urlDict["url0"], headers=LoginTask.headers4)
             self.request401 = Test(int(str(self.taskId) + str(401)), "Log into the app: POST contextSensitiveHelpProxy").wrap(self.request401)

             self.request402 = HTTPRequest(url=self.urlDict["url0"], headers=LoginTask.headers5)
             self.request402 = Test(int(str(self.taskId) + str(402)), "Log into the app: GET contextSensitiveHelpProxy").wrap(self.request402)

             self.request501 = HTTPRequest(url=self.urlDict["url0"], headers=LoginTask.headers4)
             self.request501 = Test(int(str(self.taskId) + str(501)), "Log into the app: POST calendarBackend.jsp").wrap(self.request501)

             self.parameters = \
             {
                 "appLogin1":
                 {
                     "104":
                     {
                         "msg":"Whoa,+easy+there+tiger.+You're+gonna+need+to+login+before+you+can+view+this+page."
                     }
                 },
                 "appLogin2":
                 {
                     "201":
                     {
                         "password":"abAB12!@",
                         "Login":"Login",
                         "username":"vivin"
                     }
                 },
                 "appLogin4":
                 {
                     "401":
                     {
                         "url":"/home.html"
                     },
                     "402":
                     {
                         "url":"/data/skins/techjunkie/css/TechJunkieStripped.css"
                     }
                 },
                 "appLogin5":
                 {
                     "501":
                     {
                         "calDate":"25",
                         "userId":"1",
                         "weekEndDate":"-1",
                         "weekEndMonth":"-1",
                         "weekStartDate":"-1",
                         "weekEndYear":"-1",
                         "weekStartYear":"-1",
                         "weekStartMonth":"-1",
                         "calMonth":"5",
                         "calType":"Day",
                         "calYear":"2009"
                     }
                 }
             }

             self.instrumentMethod(Test(int(str(self.taskId) + str(100)), 'Log into the app'), 'appLogin1')
             self.instrumentMethod(Test(int(str(self.taskId) + str(200)), 'Log into the app'), 'appLogin2')
             self.instrumentMethod(Test(int(str(self.taskId) + str(300)), 'Log into the app'), 'appLogin3')
             self.instrumentMethod(Test(int(str(self.taskId) + str(400)), 'Log into the app'), 'appLogin4')
             self.instrumentMethod(Test(int(str(self.taskId) + str(500)), 'Log into the app'), 'appLogin5')

      def appLogin1(self):
          """Log into the app GET index.jsp (requests 101-104)."""
          result = self.request101.GET('/')

          self.request102.GET('/slices/torqueo-crm.gif')
          # Expecting 302'Moved Temporarily'
          grinder.sleep(219)

          self.request103.GET('/login/defaultLogin.jsp')
          # Expecting 302'Moved Temporarily'
          self.token_msg = LoginTask.httpUtilities.valueFromLocationURI('msg') # Whoa,+easy+there+tiger.+You're+gonna+need+to+login+before+you+can+view+this+page.

          grinder.sleep(14)

          self.request104.GET('/index.jsp'+
              '?msg=' + self.parameters["appLogin1"]["104"]["msg"]
          )

          grinder.sleep(13)

          return result

      def appLogin2(self):
          """Log into the app GET home.jsp (requests 201-202)."""
          result = self.request201.POST('/login/processLogin.jsp',
              (
                NVPair('password', self.parameters["appLogin2"]["201"]["password"]),
                NVPair('Login', self.parameters["appLogin2"]["201"]["Login"]),
                NVPair('username', self.parameters["appLogin2"]["201"]["username"]),
              ),
              ( NVPair('Content-Type', 'application/x-www-form-urlencoded'), )
          )
          # Expecting 302'Moved Temporarily'
          self.request202.GET('/Admin/home.jsp')

          grinder.sleep(27)

          return result

      def appLogin3(self):
          """Log into the app GET popUpTask.jsp (request 301)."""
          result = self.request301.GET('/files/popUpTask.jsp' + '?0.5711548512452386')

          return result

      def appLogin4(self):
          """Log into the app GET contextSensitiveHelpProxy (requests 401-402)."""
          result = self.request401.POST('/contextSensitiveHelpProxy',
              (
                NVPair('url', self.parameters["appLogin4"]["401"]["url"]),
              ),
              ( NVPair('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8'), )
          )

          self.request402.GET('/contextSensitiveHelpProxy'+
              '?url=' + self.parameters["appLogin4"]["402"]["url"]
          )

          grinder.sleep(224)

          return result

      def appLogin5(self):
          """Log into the app POST calendarBackend.jsp (request 501)."""
          result = self.request501.POST('/Calendar/calendarBackend.jsp',
              (
                NVPair('calDate', self.parameters["appLogin5"]["501"]["calDate"]),
                NVPair('userId', self.parameters["appLogin5"]["501"]["userId"]),
                NVPair('weekEndDate', self.parameters["appLogin5"]["501"]["weekEndDate"]),
                NVPair('weekEndMonth', self.parameters["appLogin5"]["501"]["weekEndMonth"]),
                NVPair('weekStartDate', self.parameters["appLogin5"]["501"]["weekStartDate"]),
                NVPair('weekEndYear', self.parameters["appLogin5"]["501"]["weekEndYear"]),
                NVPair('weekStartYear', self.parameters["appLogin5"]["501"]["weekStartYear"]),
                NVPair('weekStartMonth', self.parameters["appLogin5"]["501"]["weekStartMonth"]),
                NVPair('calMonth', self.parameters["appLogin5"]["501"]["calMonth"]),
                NVPair('calType', self.parameters["appLogin5"]["501"]["calType"]),
                NVPair('calYear', self.parameters["appLogin5"]["501"]["calYear"]),
              ),
              ( NVPair('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8'), )
          )

          return result

      def run(self):
          """The run() method runs all the tests in this Task"""
          self.callParameterizingMethodFor('appLogin1')
          self.appLogin1()

          self.callParameterizingMethodFor('appLogin2')
          self.appLogin2()
          grinder.sleep(1584)

          self.callParameterizingMethodFor('appLogin3')
          self.appLogin3()
          grinder.sleep(364)

          self.callParameterizingMethodFor('appLogin4')
          self.appLogin4()
          grinder.sleep(54)

          self.callParameterizingMethodFor('appLogin5')
          self.appLogin5()
          grinder.sleep(155)

At first glance, this script appears to be rather similar to a recorded grinder-test-script, but there are a few important differences:

The first thing to note is that the headers are not global variables. Instead, they are class variables now. If you’ll recall, the TestRunner class in the recorded test-script had methods that corresponded to each recorded page. The situation is similar here. The LoginTask class also has methods that correspond to each recorded page (you might be wondering why they are called 'appLoginN' instead of the non-descriptive 'pageN', but I’ll get to that later). Finally, instead of the call method, LoginTask has a run() method (in retrospect, I guess I could have made the class callable, but it didn’t seem necessary. I’m also note that familiar with Python and I wanted to avoid any unpleasant surprises).

Now let’s go over the important aspects of this class:

Constructor

The constructor for LoginTask is pretty simple. In the very first line, we call the parent class’s constructor, and then we give this class a description. In the second line, the urlDict property is set to an empty dictionary. You might wonder why we aren’t actually setting any values for the URLs. The reason is that we want the parent Scenario to define the URLs for its child tasks. Therefore, the tasks themselves will not have any default URLs. Instead, they will inherit the URL values from the Scenario that they belong to. The last line is pretty important. You’ll recall that in the constructor of the Task class, the class variable numberOfTasks was incremented by one. Here, we take that value and assign it to the taskId attribute of LoginTask. What we’re essentially doing is giving each instantiated task a unique id. You’ll see why that’s important in a little bit.

initializeTask

This is probably the most important method. Here we set up data for our task and strangely enough, initialize our instance. The first thing we do is set up our requests. You’ll notice that the manner in which we do this is pretty similar to the way that the recorded test-script does it. However, in our case, for the URL values we use the urlDict dictionary, and the headers are class instance variables. Another important thing to notice is the first argument to the Test class’s constructor. You’ll notice that it is an expression that includes self.taskId. The reason we use the id of the task is because it is unique. Tests in Grinder need to have unique numbers. Remember that we want our tasks to be reusable. So it is conceivable to have a situation where we are using more than one instance of LoginTask. If we didn’t give each test a unique id and simply used the format [pageNumber][requestNumber] then there would be no way for Grinder to distinguish between tests and our results wouldn’t make very much sense. Therefore, our test numbers will be of the format [taskId][pageNumber][requestNumber]

After initializing our requests, we set up the parameters dictionary. This dictionary is important in two ways. First, it maintains a list of name-value pairs that are used in POSTs and GETs, and second, it helps us parameterize those values (by letting us directly modify it). In the initializeTask method, the parameters dictionary is initially set up with values collected by the recorder. This way, we have sensible defaults. The parameters dictionary is a 3-level dictionary. The very first level has keys that correspond to method names within the class. The second level consists of keys that match up with request numbers. The final level contain the parameter names themselves (used in GETs or POSTs). So, to access a parameter value you essentially do this: parameters[methodName][requestNumber][parameterName]. To understand the rationale behind this design, think back to the diagram that describes the relationship between requests and tasks. A set of requests belong to a page, so it makes sense to have the top level of the hash to be method names (since they correspond to each recorded page). Then, the bottommost level will contain parameter names that belong to a particular request.

The last set of statements are similar to the calls to instrumentMethod in a typical recorded-script. Notice, however, that once again we use the taskId property to ensure that we have unique test numbers.

Page methods

Similar to a typical recorded test-script, the page methods of the LoginTask class correspond to each unique page that was recorded during the task. The code in these methods is very similar to the one in a recorded test-script. However, in the original script, parameter values within a querystring (for GETs) were pulled out into "tokens" and were turned into instance variables of the TestRunner class. For example, the querystring parameter msg would become self.token_msg. In our framework, however, the querystring parameters and their default values are stored in the parameters dictionary and accessed the same way that POST parameters and values are.

run() method

The run() method is similar to the call() method in a recorded test-script. The only difference is that call the callParameterizingMethodFor method before we actually call the page method. You should now be able to see how we can easily parameterize the values used by requests in a page method. The parameterizing method will modify the values in the parameters hash, and these values will be used by the requests in the corresponding page method.

Hopefully you now have a clearer picture of the framework. You should now be able to see how we can design tasks to be discrete units that we can reuse, customize, and parameterize to our wishes. Even better: you don’t have to write the code for these tasks from scratch. I’ve written a translator script in Perl that converts the XML data recorded by TCPProxy and converts it into a class like the one above (I’ll talk about that later). Now that you’ve seen how a task is designed, let’s go up one level and take a look at the Scenario class.

The Scenario class

The Scenario class (as the name suggests) describes a particular scenario. It contains a list of tasks that it will execute sequentially. It looks like this:

Scenario.py

import warnings

class Scenario:
      def __init__(self, description, urlDict):
          if(len(urlDict.keys()) == 0):
             raise Exception("Cannot set " + self.__class__.__name__ + ".urlDict to an empty dictionary.")
          else:
             self.description = description
             self.urlDict = urlDict
             self.tasks = []

      def addTask(self, task):
          if(hasattr(task, "setUrlDict")):
             task.setUrlDict(self.urlDict)
          else:
             raise Exception(task.__class__.__name__ + " does not implement setUrlDict()!")

          if(hasattr(task, "initializeTask")):
             task.initializeTask()
          else:
             raise Exception(task.__class__.__name__ + " does not implement initializeTask()!")

          if(hasattr(task, "run")):
             self.tasks.append(task)
          else:
             raise Exception(task.__class__.__name__ + " does not implement run()!")

      def setUrlDict(self, url):
          warnings.warn("Scenario.urlDict is a read-only property that can only be initialized in the constructor.")

      def getUrlDict(self):
          return self.urlDict

      def setUrl(self, key, value):
          warnings.warn("Cannot modify Scenario.urlDict because it is a read-only property")

      def getUrl(self, key):
          return self.urlDict[key]

      urlDict = property(getUrlDict, setUrlDict)

      def run(self):
          for task in self.tasks:
              if(hasattr(task, "run")):
                 grinder.logger.output("Running " + task.__class__.__name__ + " now!", grinder.logger.TERMINAL)
                 task.run()
              else:
                 raise Exception(task.__class__.__name__ + " does not implement run()!")

Constructor

The constructor for the Scenario class accepts two arguments: a description and a dictionary for URLs. As mentioned earlier, each task has a URL (or URLs) associated with it. By setting a URL for a scenario, you cause all tasks added to that particular task to have the same URL. Simce the tasks by themselves do not have any previously defined URLs, we need to make sure that the supplied dictionary is not empty. The constructor also initializes a list called tasks which of course, maintains a list of tasks.

addTask method

The addTask() method lets you add a Task to a Scenario. First we check to see if the object passed in implements the setUrlDict() method. If it doesn’t we raise an exception. If it does implement the method, we check to see whether the current instance of the Scenario class has a non-empty urlDict dictionary. If it does, we go ahead and set the task’s urlDict property to the same value. As a side note, Python doesn’t have explicit interfaces. The language has implied interfaces. Hence, we need to inspect the object at runtime to see whether it implements a necessary method.

The next if-statement checks to see whether the supplied object implements the initializeTask() method. If it does, we go ahead and run that method.

Finally, we check to see if the supplied object implements the run() method, since it makes no sense to add a task that we cannot run. If it does, we add it to our list of tasks.

Accessor methods

The class has two accessor methods: getUrl() and getUrlDict() which perform the same functions as the Task base classes’s getUrl() and getUrlDict() methods.

Mutator methods

You might be wondering why the mutator methods don’t actually let you change anything. The reason is because all the child tasks of a Scenario must have the same URL. As you can see, we initialize the URL of a task only when it is added. If you were allowed to change the URL of a Scenario later, then the tasks wouldn’t have the same URL as the scenario. Of course, you could then say that in the setUrlDict() or setUrl() methods we just need to update all the tasks with the new URL. But that would be a side-effect, and side-effects are not good! Finally, there simply is no reason to change the URL of a Scenario during runtime. Since the tests themselves are tied to a task, and therefore to a scenario, altering the URL of a scenario while the test is running would make your statistics meaningless.

run() method

The run() method iterates through all the tasks belonging to this scenario.

Now that we’ve gone over the building blocks of the framework, let’s use them to create a scenario that we want to test.

A Login and Logout Scenario

Our first scenario is going to be a very simple one. We’re just going to log into the application and then log out. We’re going to use default values and aren’t going to parameterize anything:

login-logout.py

from torqueo.test.framework.Scenario import Scenario
from torqueo.test.tasks.login.LoginTask import LoginTask
from torqueo.test.tasks.login.LogoutTask import LogoutTask

#
# Make a new scenario
#

myScenario = Scenario("My Scenario", {"url0":"https://local.sitetotest.com:8443"});

# Create a new instance of the Login Task
loginTask = LoginTask()

# Create a new instance of the Logout Task
logoutTask = LogoutTask()

#Add the tasks to our scenario
myScenario.addTask(loginTask)
myScenario.addTask(logoutTask)


class TestRunner:
   def __call__(self):
      myScenario.run()

Pretty simple, right? First we create an instance of a Scenario. We give it a description and also a dictionary containing the URL we’re going to be using. Then, we create an instance of a login task, a logout task, and add it to our scenario. Finally, we run our scenario in the call method. Now what if we want to parameterize the logins? That’s not difficult either:

login-logout-parameterized.py:

from torqueo.test.framework.Scenario import Scenario
from torqueo.test.tasks.login.LoginTask import LoginTask
from torqueo.test.tasks.login.LogoutTask import LogoutTask

#
# Make a new scenario
#

myScenario = Scenario("My Scenario", {"url0":"https://local.sitetotest.com:8443"});

# Create a new instance of the Login Task
loginTask = LoginTask()

# Define our login credentials

loginCredentials = [{"username":"vivin", "password":"abAB12!@"},
                    {"username":"jimbo", "password":"abAB12!@"},
                    {"username":"hippy", "password":"abAB12!@"},
                    {"username":"flippy", "password":"abAB12!@"},
                    {"username":"batman", "password":"abAB12!@"},
                    {"username":"beverly", "password":"abAB12!@"},
                    {"username":"worf", "password":"abAB12!@"},
                    {"username":"deanna", "password":"abAB12!@"},
                    {"username":"jean-luc", "password":"abAB12!@"},
                    {"username":"laren", "password":"abAB12!@"},
                    {"username":"will", "password":"abAB12!@"},
                    {"username":"tasha", "password":"abAB12!@"},
                    {"username":"geordi", "password":"abAB12!@"},
                    {"username":"data", "password":"abAB12!@"},
                    {"username":"wesley", "password":"abAB12!@"}]

# Set loginCredentials as a new property of loginTask since we're going to be
# using it in our parameterizing method
setattr(loginTask, "loginCredentials", loginCredentials)

# Define our parameterizing method

def parameterizeLogin(self=loginTask):
    loginCredential = self.loginCredentials[grinder.threadNumber];
    username = loginCredential["username"];
    password = loginCredential["password"];

    self.parameters["appLogin2"]["201"]["username"] = username;
    self.parameters["appLogin2"]["201"]["password"] = password;

# Set parameterizeLogin the parameterizing method for "appLogin2"
loginTask.setParameterizingMethodFor("appLogin2", parameterizeLogin)

# Create a new instance of the Logout Task
logoutTask = LogoutTask()

#Add the tasks to our scenario
myScenario.addTask(loginTask)
myScenario.addTask(logoutTask)

class TestRunner:
   def __call__(self):
      myScenario.run()

Here we do two additional things. After creating an instance of LoginTask, we create an array of name-value pairs and assign it to loginCredentials. We then create a new property for this particular instance of LoginTask called loginCredentials, and set the value of that property to the array we just defined. The reason we do this is because we’re going to be refering to this array in our parameterizing method. Then, we actually define the parameterizing method. In the method, we choose a name-value pair out of the array using the current thread number as an index. We then use the username and password in the name-value pair as our login credentials. What we’re essentially doing is modifying the username and password parameters for request number 201 in the appLogin2 method of LoginTask. The neat thing is that you can put whatever logic you want inside the parameterizing method. You could even read from a file if you wanted to.

Finally, here is one more example. It’s a bit more complicated, but see if you can figure out what it’s trying to do. I’m providing a link to it (/api/media/5e9b98e5-60e1-453c-bce0-8fa1f5bcadf4/content]) since it’s a little long. The code is commented so that should help :): I hope this demonstrates the power and flexibility of the testing framework. Now you’re ready to write your own tests using this framework! But before that, you’ll need to know how to translate the data from TCPProxy.

Using xmlToJython.pl /api/media/5e9b98e5-60e1-453c-bce0-8fa1f5bcadf4/content] is a handy script that I wrote, that translates XML header data from TCPProxy into a Jython class. Before you use it, you need to have TCPProxy return XML data instead of Jython. You will need to create a new shellscript:

#!/bin/bash
. setGrinderEnv.sh
java -cp $CLASSPATH net.grinder.TCPProxy -http $GRINDERPATH/etc/httpToXML.xsl -console > grinder.xml

Then, you’ll need to use xmlToJython.pl to convert the XML file into Jython. The syntax for xmlToJython.pl is this:

xmlToJython.pl input.xml [output.py]

The output file is optional. If one is not provided, the script just outputs to STDOUT. The script requires that the XML::Simple module has been installed. The easiest way to do that is through cpan (a commandline tool to install Perl modules). I’m going to see if I can compile the script into a binary so that you don’t have to jump through hoops to use it.

A few more tips…​ xmlToJython.pl has a feature that lets you make your generated script a bit nicer to read. In the code samples above, you’ll notice that the methods had nice names like "appLogin1" instead of "page1", and that the description strings for the tests were actually informative instead of just "GET /somefile.html". Here’s how you can do that: When you start up TCPProxy, you’ll notice that you can add comments. If you make sure that your very first comment is of the form:

@NameOfTaskClass pageMethodName:Description of Task

Then, your recorded class will be called NameOfTaskClass, your page methods will start with pageMethodName, and your test descriptions will have "Description of Task" prepended to them. Typically you should only need that very first comment since you’re only recording one discrete task.

Package Structure

For the framework, I created a package structure (you can see it in the way I import the task files). Within my performance-testing directory where I have Grinder installed, I created a scripts directory, inside which I place the package:

scripts/
|-- grinder-frameworked.py
|-- grinder.properties
|-- grinder.py
`-- torqueo
    |-- __init__.py
    `-- test
        |-- __init__.py
        |-- framework
        |   |-- Scenario.py
        |   |-- Task.py
        |   `-- __init__.py
        `-- tasks
            |-- Affiliate
            |   |-- AddAffiliateTask.py
            |   `-- __init__.py
            |-- Appointment
            |   |-- AddAppointmentTask.py
            |   `-- __init__.py
            |-- Contact
            |   |-- AddContactTask.py
            |   `-- __init__.py
            |-- Opportunity
            |   |-- AddOpportunityTask.py
            |   `-- __init__.py
            |-- Reports
            |   |-- SearchAffiliateTask.py
            |   |-- SearchContactTask.py
            |   |-- SearchOpportunityTask.py
            |   `-- __init__.py
            |-- Task
            |   |-- AddTaskTask.py
            |   `-- __init__.py
            |-- __init__.py
            `-- login
                |-- LoginTask.py
                |-- LogoutTask.py
                `-- __init__.py

Of course, you can create it in any manner you see fit; this is just the way I did it.

Well, that’s the end of this tutorial! Hope it was informative and helpful! Please comment and let me know where I can improve. This was my first time doing a lot things; it was my first time writing a framework, my first time doing performance testing, and my first time doing anything serious (and by serious I mean more serious than "Hello World!") in Python/Jython.

Download

If you want to check out the framework, here are the archives. They contain the Task and Scenario classes, and the Perl conversion script:

  • /api/media/5e9b98e5-60e1-453c-bce0-8fa1f5bcadf4/content]

  • /api/media/5e9b98e5-60e1-453c-bce0-8fa1f5bcadf4/content]