Anatomy of a Grinder test-script
This is my second post regarding Grinder. In this post I’ll go over the anatomy of a recorder Grinder test-script. If you haven’t read my previous post, please take a look at it. Otherwise this post won’t make much sense!
High-level structure of a Grinder script
The high-level structure of a recorded Grinder-test-script looks like this:
[ ... import statements ... ] [ ... header definitions ... ] [ ... url definitions ... ] [ ... request and test definitions ... ] class TestRunner: [ ... method definitions - a method is defined for each recorded page ... ] def __call__(self): [ ... calls to defined methods, which actually runs the requests ... ] [ utility function (I'll go over this later) ] [ ... calls to utility function to wrap/instrument tests (I'll go over this later) ... ]
Let’s go over each of these sections one by one:
If you know Java, then this section is pretty much self-explanatory. Here you import the stuff you need, to make the tests work. By default, the following libraries are imported:
from net.grinder.script import Test from net.grinder.script.Grinder import grinder from net.grinder.plugin.http import HTTPPluginControl, HTTPRequest from HTTPClient import NVPair
The first two import statements import the Test and grinder objects, which give you access to Grinder’s testing framework, and the grinder environment. The next two import statements import some utility classes which perform HTTP Requests. The NVPair class lets you organize parameters and values for POST requests into name-value pairs.
When the recorder runs, it records every request that the browser makes. After that, it goes through all the requests and generates a set of unique headers. These headers are then used to create the request objects (which we’ll look at later). The generated code looks like this:
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 level of the file are evaluated once, # when the worker process is started. connectionDefaults.defaultHeaders = \ ( NVPair('Accept-Language', 'en-us,en;q=0.5'), NVPair('Accept-Charset', 'ISO-8859-1,utf-8;q=0.7,*;q=0.7'), NVPair('Accept-Encoding', 'gzip,deflate'), NVPair('User-Agent', 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:126.96.36.199) Gecko/2009060308 Ubuntu/9.04 (jaunty) Firefox/3.0.11'), ) headers0= \ ( NVPair('Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'), NVPair('Referer', 'https://local.infusiontest.com:8443/?msg=You%27ve+been+logged+out+-+thanks+for+playing%21&amp;amp;amp;notification=You%27ve+been+logged+out+-+thanks+for+playing%21'), ) headers1= \ ( NVPair('Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'), NVPair('Referer', 'https://local.infusiontest.com:8443/Admin/home.jsp'), )
The first statement creates an HTTPPluginConnection object, which lets you control the behavior of the connection. The next statement creates an instance of an HTTPUtilities object which provides access to various utility methods (you’ll see examples of this later). The next two commented lines let you know that you can proxy these requests through a proxy server if you wish. The two comments after that let you know that these definitions are evaluated once for each process. Essentially, all data defined at the top of a script, outside the class, are shared between threads belonging to a single worker process.
In the next few lines, the script actually defines the headers. First, it sets up the default headers that are used by each request (things like the User-Agent, Charset, and Encoding). After that, it goes through and defines the headers. As you can see, these headers are basically tuples of NVPair objects. One NVPair defines the Accept parameter, and the other defines the Referer. Those with an eye for detail will notice that there seems to be an extra comma at the end of the tuple definition. This isn’t a problem in Python because the parser will treat the last element as an empty element.
In the next section of the script, you’ll find URL definitions, which look like this:
url0 = 'https://local.infusiontest.com:8443'
In this example, there is only one URL. But let’s say that the app you’re testing hits a few different URL’s during the scenario that you’re recording. Then the script will have a definition for each unique URL that the recorder encountered. The definitions are of the form urlN = “protocol://recorded.url.here”. The combination of a URL and Header is what defines a request (more on that in the next section).
Request and test definitions
In the next section of the script, we define the actual requests that we’re going to use for our tests:
# Create an HTTPRequest for each request, then replace the # reference to the HTTPRequest with an instrumented version. # You can access the unadorned instance using request101.__target__. request101 = HTTPRequest(url=url0, headers=headers0) request101 = Test(101, 'POST processLogin.jsp').wrap(request101) request102 = HTTPRequest(url=url0, headers=headers0) request102 = Test(102, 'GET home.jsp').wrap(request102) request201 = HTTPRequest(url=url0, headers=headers1) request201 = Test(201, 'GET popUpTask.jsp').wrap(request201) request301 = HTTPRequest(url=url0, headers=headers2) request301 = Test(301, 'POST calendarBackend.jsp').wrap(request301)
The comments give a terse explanation of what’s happening, but allow me to elaborate. First, you create an instance of an HTTPRequest object using the previously defined URL’s and Headers. An important thing to note here is that there is a naming scheme for the requests, which is of the form request[Page Number][Request Number]. So, for example, request105 means the fifth request in the first recorded page, and request1101 means the first request in the eleventh recorded page (yes, there is an inherent ambiguity, because 1101 could also mean the one hundred and first request in the first recorded page. But if you have a hundred and one unique requests per page, then you have a problem).
The next statement is the most important one. The way Grinder records test statistics is by proxying whatever you want to test, through the Test object. What this means is that the Test object is a wrapper around the object you want to test. You can still treat the wrapped object like the original object, but under the hood, access to the methods of that object are proxied through the Test object. This way, Grinder can record statistics. The picture might help understand what’s going on:
On the top you can see how a regular request behaves when accessing the GET method. It’s a straight call. The bottom picture shows you what happens when you wrap your request object with a Test object. Now, the call to GET isn’t made on the actual request object. It’s proxied through the Test object, which has an attribute called __target__ (which is the actual request object). Once it gets to the actual request object, the actual GET method is called. The reason Grinder does this is so that it can collect statistics on actions performed by an object.
After the request objects have been defined, we come to the actual TestRunner class. This is where you define methods that correspond to each recorded page:
# A method for each recorded page. def page1(self): """GET / (requests 101-104).""" result = request101.GET('/') grinder.sleep(307) # Expecting 302 'Moved Temporarily' request102.GET('/slices/infusion-crm.gif') grinder.sleep(29) # Expecting 302 'Moved Temporarily' request103.GET('/login/defaultLogin.jsp') self.token_msg = \ httpUtilities.valueFromLocationURI('msg') # 'Whoa,+easy+there+tiger.+You\'re+gonna+nee...' grinder.sleep(24) request104.GET('/index.jsp' + '?msg=' + self.token_msg) return result def page2(self): """POST processLogin.jsp (requests 201-202).""" # Expecting 302 'Moved Temporarily' result = request201.POST('/login/processLogin.jsp', ( NVPair('username', 'vivin'), NVPair('password', 'abAB12!@'), NVPair('Login', 'Login'), ), ( NVPair('Content-Type', 'application/x-www-form-urlencoded'), )) grinder.sleep(72) request202.GET('/Admin/home.jsp') return result
In the script, you have a method for each recorded page, which are called page1, page2, and so forth. Within each page, there are one or more GET and/or POST requests made via the request objects, which simulate the transactions made by a single page. Let’s take a look at the page1 method. In this method you can see that it makes three requests. The first two requests are simple requests without any parameters. The last request, however, is a GET request with a querystring. The script uses the httpUtilities variable (defined earlier, at the beginning of the script) to get the value of the msg parameter from the URI, and constructs the querystring for the request.
The page2 method provides an example of a POST request. The values to the POST are supplied in name-value pairs using the NVPair object. Also notice the calls to grinder.sleep(). These calls simulate “think time”.
In both methods, you can see that the return value from the first request is assigned to a variable called result. The method then returns this variable (which contains statistics from Grinder).
Utility method, and calls to the utility method
I’m going to go out of order here and talk about the instrumentMethod utility method before I talk about the __call__ method. The instrumentMethod and the calls to that method look like this:
def instrumentMethod(test, method_name, c=TestRunner): """Instrument a method with the given Test.""" unadorned = getattr(c, method_name) import new method = new.instancemethod(test.wrap(unadorned), None, c) setattr(c, method_name, method) # Replace each method with an instrumented version. # You can call the unadorned method using self.page1.__target__(). instrumentMethod(Test(100, 'Page 1'), 'page1') instrumentMethod(Test(200, 'Page 2'), 'page2') instrumentMethod(Test(300, 'Page 3'), 'page3') instrumentMethod(Test(400, 'Page 4'), 'page4') instrumentMethod(Test(500, 'Page 5'), 'page5') instrumentMethod(Test(600, 'Page 6'), 'page6') instrumentMethod(Test(700, 'Page 7'), 'page7')
The instrumentMethod method is another way to proxy calls through the Test object, except, instead of proxying an object, it proxies a method. To accomplish this, instrumentMethod performs some metaprogramming. Metaprogramming lets you modify a class’s properties and methods during runtime. You can add, remove, and modify existing methods and properties. The instrumentMethod method has three arguments. The first argument is a test object, the second is the method name (as a string), and the third argument is a default argument that is set to TestRunner. First, instrumentMethod gets a reference to a TestRunner class method identified by method_name (assigned to the variable unadorned). Then it creates a new method (using new.instancemethod) which has the test object wrapped around the original method. instrumentMethod then assigns this new method back to the TestRunner class, effectively proxying all calls to the original method through the Test object.
In Python any method that implements the __call__ is _callable_, which basically means that this function is called when you call the class instance as a function. In the context of the this script, it just means that this is where all the testing starts off. In the __call__ method you can see that there are calls to all the recorded page methods (along with think time), and this essentially runs the recorded test.
Now you should have a general idea of what a recorded Grinder test-script looks like. In my next tutorial, I’ll go over writing performance tests in Grinder using a testing framework.