GitHub user duderino opened a pull request: https://github.com/apache/trafficserver/pull/141
Add initial python-based functional test framework with example test # Apache Traffic Server Functional Test Framework The goal of the functional test framework is to allow traffic server contributors to develop and exercise a reasonable functional test framework from the comfort of their development environments. This encourages traffic server developers to write their own functional test cases (it's not the province of a detached QA team). This also gives traffic server developers critical test feedback as early in the development process as possible. The framework consists of a 'process manager' responsible for configuring and spawning 1+ traffic server processes, a configuration-driven reusable origin process so you don't have to write your own for every test case (though sometimes you will), and a small number of utilities for the test cases themselves. In this context the test cases are traffic server HTTP/SPDY clients. That is: * test cases/clients --> traffic server --> (configurable/reusable origin | ad hoc origin) ## Framework Prerequisites * Python 2.6+ (the default python on RHEL, CentOS, etc. 6.5+) * [Twisted](https://twistedmatrix.com/trac/) 14.0.2+ 1. [Install pip](https://pip.pypa.io/en/latest/installing.html) 2. Install twisted and its dependencies using pip: ``` $ sudo pip install --upgrade pyOpenSSL zope.interface twisted ``` Individual test cases may add additional dependencies. ## The ProcessManager class: The ProcessManager class can be found in tests/framework/atstf.py. It is responsible for configuring, starting, and stopping the processes used by a test suite. ### ProcessManager Configuration The ProcessManager is driven by a JSON config file that describes all managed processes. Individual test cases can also read this config file to discover server ports, hostnames, etc. As long as test cases do not hardcode these values they can later be turned against an external functional test environment. #### config.json path By default the ProcessManager looks for a 'config.json' file in the test suite's current working directory. An alternate location can be passed to its constructor: ```python tm = atstf.ProcessManager(config_path="/tmp/config.json") ``` #### trafficserver and other paths Test suites inside the trafficserver source repo only have to tell the ProcessManager the path to the source repo root and it will infer the rest: ```python tm = atstf.ProcessManager(root_path="../..") ``` Test suites outside the trafficserver source repo have to explicitly set each of the following paths: ```python tm = atstf.ProcessManager(ats_path="/usr/local/bin/trafficserver", default_config_path="~/trafficserver/proxy/config", origin_path="~/trafficserver/tests/framework/origin.py") ``` The last two paths (default_config_path and origin_path) are paths into a local trafficserver source repo, but we can potentially incorporate those into a 'devel' package in the future. #### logging: You can tell the ProcessManager to log stuff including the stdout and stderr of the processes it manages. The ProcessManager uses python's [logging module](https://docs.python.org/2/library/logging.html). If you configure python's logging module outside of ProcessManager, the ProcessManager will just use that config. If you don't want to configure the logging module you can tell the ProcessManager to do it for you by passing it a log_level (e.g., logging.INFO): ```python import logging tm = atstf.ProcessManager(log_level=logging.DEBUG) ``` #### max start time By default the ProcessManager will wait for up to 5 seconds for the processes it manages to start. If any managed process does not start in time, the ProcessManager will throw an exception which should fail the test. The ProcessManager considers a managed process to have started when it can successfully connect to all ports listed in the process's 'interfaces' section. More on this later. To change the max start time: ```python tm = atstf.ProcessManager(max_start_sec=3) ``` #### The config.json The JSON configuration file used by the ProcessManager has the following common format: <pre> { "processes": { <process name>: { "type": <"ats"|"origin"|"python"|"other">, "spawn": <true|false> "interfaces": { <interface name>: { "hostname": <hostname|ipv4 addr|ipv6 addr>, "port": <port> } }, <other stuff specific to the process type> } }, <optional: other stuff specific to the test suite. Ignored by ProcessManager> } </pre> Because this must be valid JSON, process names must be unique. They will be incorporated into log messages and into exceptions thrown by the ProcessManager. The process types supported by the ProcessManager are described below. Different process types may support additional configuration options. The 'spawn' attribute defaults to true. If set to false the ProcessManager will not manage the process. This is intended to support turning a functional test case against an externally managed set of processes. The test case code can still discover hostnames and ports from the config.json without a code change. The optional 'interfaces' attribute details which ports will be opened by a managed process. If specified the ProcessManager will block caller until either all ports are up or the 'max start time' is exceeded. #### The config.json Process Types The ProcessManager currently supports the following process types: ##### Process Type 'ats' 'ats' processes support additional config.json options. Example: ```json { "servers": { "ats1": { "type": "ats", "root": "ats1", "interfaces": { "http": { "type": "http", "hostname": "localhost", "port": 8080 } }, "config": { "records.config": [ { "match": "^CONFIG proxy.config.diags.debug.enabled", "substitute": "CONFIG proxy.config.diags.debug.enabled INT 1" } ], "remap.config": [ { "append": "map /foo/bar http://localhost:8082/foo/bar" } ] } } } } ``` LIMITATION: Only the 'http' interface type is presently supported. Need to add HTTPS and SPDY ASAP. The ProcessManager will run the trafficserver in the 'root' dir. This is the same as the trafficserver 'TS_ROOT' environment variable. If you configure multiple 'ats' processes, be sure to assign each of them a distinct root dir. The root dir should be relative to the test case dir and MUST NOT CONTAIN ANYTHING IMPORTANT! If ats crashes and the system is configured to capture cores, they should appear in the root dir. Before trafficserver is started: * The ProcessManager will rm -rf the 'root' dir if it already exists, then recreate it with a minimal directory structure (a few var dirs and an etc dir) * The ProcessManager will copy over all the default config files and body factory files from proxy/config * If the config.json has a 'config' section, the ProcessManager will transform the copied default config files accordingly. On a per config file basis: * match/substitue directives will replace any line that matches a regex (match) with a substitution line * append directives will simply add lines to the end of the config file. After trafficserver is started: * trafficserver will create transient files in the root/var dirs, including log files ##### Process Type 'origin' A configurable origin server. Multiple types of 'origins' can be specified on a per-path basis, but currently only a 'chunkedresponse' type has been developed. The 'chunkedresponse' origin type supports the following configuration: 1. response status code 1. response headers 1. number of chunks to send in the response 1. size of each chunk 1. byte value of each byte in chunk 1. seconds to wait before sending the first chunk 1. seconds to wait before sending all subsequent chunks Example: ```json { "servers": { "origin1": { "type": "origin", "interfaces": { "http": { "type": "http", "hostname": "localhost", "port": 8082 } }, "actions": { "GET": { "/foo/bar": { "type": "chunkedresponse", "status_code": 200, "headers": { "content-type": "text/plain" }, "delay_first_chunk_sec": 1, "chunk_size_bytes": 1024, "num_chunks": 10, "delay_between_chunk_sec": 0, "chunk_byte_value": 99 } } } } } } ``` ##### Process Type 'python' The ProcessManager can manage ad hoc origins written in python providing they exit cleanly when sent a SIGTERM. If they specify an 'interfaces' section then the ProcessManager will also wait for them to start. Example: ```json { "servers": { "python1": { "type": "python", "script": "ad_hoc_origin.py", "args": [ "python1", "config.json" ], "interfaces": { "http": { "type": "http", "hostname": "localhost", "port": 8082 } } } } } ``` ##### Process Type 'other' Finally the ProcessManager can manage any executable, again providing it exits cleanly when sent a SIGTERM. If it specifies an 'interface' secion then the ProcessManager will also wait for it to start. Example: ```json { "servers": { "other1": { "type": "other", "spawn": false, "executable": "/usr/local/apache2/bin/httpd", "args": [ "-f", "/usr/local/apache2/conf/httpd.conf", "-k", "start", "-x" ], "interfaces": { "http": { "type": "http", "hostname": "localhost", "port": 8082 } } } } } ``` ## Writing Test Cases ### Using the 'unittest' and 'twisted' modules Test cases should use [the Python Standard Library's unittest module](https://docs.python.org/2/library/unittest.html). Test cases also need to send traffic to trafficserver. This particular example uses the [Python twisted framework](https://twistedmatrix.com/trac/), but other approaches are possible. The main advantage of twisted is that it can be used for lightweight load testing. The main disadvantage is that Python's unittest module implements assertions on top of exceptions. Failed assertions in twisted callbacks will be eaten by the twisted framework and will not be incorporated into the test results. This example works around this issue by saving the state of its interaction with trafficserver, then performing all assertions in the main thread against the saved state at the end of the test case: ```python import sys import unittest from twisted.internet import reactor from twisted.internet.defer import Deferred from twisted.internet.protocol import Protocol from twisted.web.client import Agent from twisted.web.http_headers import Headers sys.path = ['../framework'] + sys.path import atstf class ExampleTwistedTest(unittest.TestCase): def test_ats1(self): # # Read test case configuration from the same config file used to start ATS and origin processes. # conf = atstf.parse_config() ats1_conf = conf['processes']['ats1'] hostname = ats1_conf['interfaces']['http']['hostname'] port = ats1_conf['interfaces']['http']['port'] origin_conf = conf['processes']['origin1'] expected_status_code = origin_conf['actions']['GET']['/foo/bar']['status_code'] chunk_size_bytes = origin_conf['actions']['GET']['/foo/bar']['chunk_size_bytes'] num_chunks = origin_conf['actions']['GET']['/foo/bar']['num_chunks'] expected_bytes_received = chunk_size_bytes * num_chunks # # Create a twisted HTTP client that will interact with ATS and save all details of the interaction. We'll # later assert that the saved details are correct. Ideally we'd just throw assertions into the client itself, # but sadly assertions are implemented with exceptions and twisted eats any exception raised by our callbacks. # # See https://twistedmatrix.com/documents/current/web/howto/client.html for details on writing HTTP clients # agent = Agent(reactor) request = agent.request('GET', "http://%s:%d/foo/bar" % (hostname.encode("utf-8"), port), Headers({'User-Agent': ['ExampleTwistedTest']}), None) response_handler = ResponseHandler(reactor) request.addCallback(response_handler.handle_response) request.addCallback(response_handler.handle_completion) request.addErrback(response_handler.handle_error) reactor.run() # # Now assert # self.assertEqual(expected_status_code, response_handler.get_status_code()) self.assertEqual(expected_bytes_received, response_handler.get_body_handler().get_bytes_received()) self.assertEqual("Response body fully received", \ response_handler.get_body_handler().get_reason().getErrorMessage()) class ResponseBodyHandler(Protocol): def __init__(self, deferred): self.__deferred = deferred self.__bytes_received = 0 self.__reason = None def dataReceived(self, data): self.__bytes_received += len(data) def connectionLost(self, reason): # # This function name is highly misleading. According to the web client docs: # # When the body has been completely delivered, the protocol's connectionLost method is called. It is important # to inspect the Failure passed to connectionLost . If the response body has been completely received, the # failure will wrap a twisted.web.client.ResponseDone exception. This indicates that it is known that all data # has been received. It is also possible for the failure to wrap a twisted.web.http.PotentialDataLoss exception: # this indicates that the server framed the response such that there is no way to know when the entire response # body has been received. Only HTTP/1.0 servers should behave this way. Finally, it is possible for the # exception to be of another type, indicating guaranteed data loss for some reason (a lost connection, a memory # error, etc). # self.__reason = reason self.__deferred.callback(None) def get_bytes_received(self): return self.__bytes_received def get_reason(self): return self.__reason class ResponseHandler: def __init__(self, reactor): self.__reactor = reactor self.__deferred = Deferred() self.__body_handler = ResponseBodyHandler(self.__deferred) self.__error = None self.__status_code = None def handle_response(self, response): self.__status_code = response.code response.deliverBody(self.__body_handler) return self.__deferred def handle_completion(self): self.__reactor.stop() def handle_error(self, error): self.__error = error self.__reactor.stop() def get_body_handler(self): return self.__body_handler def get_error(self): return self.__error def get_status_code(self): return self.__status_code ``` ### Pulling it all together with a Test Suite Multiple test cases should be run together in a test suite and the results of the entire suite should be summarized in a JUnit report suitable for Jenkins. This is also a good place to instantiate and start the ProcessManager (start it before any test case is invoked, stop it after all test cases finish). Example: ```python #!/bin/env python import sys import logging sys.path = ['../framework'] + sys.path import atstf from twisted_test import ExampleTwistedTest test_cases = [ExampleTwistedTest] if __name__ == '__main__': # Spawn ATS and origin processes tm = atstf.ProcessManager(root_path="../..", config_path="config.json", log_level=logging.DEBUG, max_start_sec=5) try: tm.start() # Run the test suite and generate a JUnit XML report if atstf.run_tests(test_cases=test_cases, name='example', report='report.xml'): sys.exit(0) else: sys.exit(1) finally: tm.stop() ``` Here the important bits are: 1. Initializing the ProcessManager and starting it before the running the test suite 1. Passing all the test case classes to run_tests(). This will auto discover all test cases in the test classes, run them, and spit the results out in a report.xml file in the cwd. 1. Exiting zero if the run_tests() method returns True, non-zero otherwise Not so important is explicitly stopping the ProcessManager. It's good practice, but the ProcessManager will stop automatically on process exit. You can merge this pull request into a Git repository by running: $ git pull https://github.com/yahoo/trafficserver blattj_functional_test_framework_redux Alternatively you can review and apply these changes as the patch at: https://github.com/apache/trafficserver/pull/141.patch To close this pull request, make a commit to your master/trunk branch with (at least) the following in the commit message: This closes #141 ---- commit 077d1891f39f2978e9b5936e4a5dbaa106bc4804 Author: Joshua Blatt <bla...@yahoo-inc.com> Date: 2014-11-11T20:30:55Z Add initial python-based functional test framework with example test case illustrating use of twisted. ---- --- If your project is set up for it, you can reply to this email and have your reply appear on GitHub as well. If your project does not have this feature enabled and wishes so, or if the feature is enabled but not working, please contact infrastructure at infrastruct...@apache.org or file a JIRA ticket with INFRA. ---