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.
---

Reply via email to