Test scenario creation¶
Writing tests to determine whether a piece of code behaves as expected is an important part of the software development process. With Switchyard, it is possible to create a set of tests that verify whether a program attempts to receive packets when it should and sends the right packet(s) out the right ports. This section describes how to construct such tests.
A test scenario is Switchyard’s term for a series of tests that verify a program’s behavior. A test scenario is simply a Python source code file that includes a particular variable name (symbol) called scenario
, which must refer to an instance of the class TestScenario
. A TestScenario
object contains the basic configuration for an imaginary network device along with an ordered series of test expectations. These expectations may be one of three types:
that a particular packet should arrive on a particular interface/port,
that a particular packet should be emitted out one or more ports, and
that the user program should time out when calling
recv_packet
because no packets are available.
To start off, here is an example of an empty test scenario:
from switchyard.lib.userlib import *
scenario = TestScenario("test example")
If we run swyard
in test mode using this test description and any Switchyard program, here’s the output we should see:
Results for test scenario test example: 0 passed, 0 failed, 0 pending
All tests passed!
Notice that in the above example code, we assigned the instance of the TestScenario
class to a variable named scenario
. An assignment to this variable name is required. If it is not found, you’ll get an ImportError
exception. Notice also that there’s one parameter to TestScenario
: this value can be any meaningful description of the test scenario.
There are two methods on TestScenario
that are used to configure the test environment:
add_interface(name, macaddr, *ipaddrs, **kwargs)
This method adds an interface/port to an imaginary network device that is the subject of the test scenario. For example, if you are creating a test for an IP router and you want to verify that a packet received on one port is forwarded out another (different) port on the device, you will need to add at least two interfaces. Arguments to the
add_interface
method are used to specify the interface’s name (e.g.,en0
), its hardware Ethernet (MAC) address, and zero or more IP addresses, which must be in the formaddr/prefixlen
, e.g.,fe80::13/64
.Two optional keyword arguments can also be given:
ifnum
can be used to explicitly specify the number (integer) associated with this interface, andiftype
can be used to explicitly indicate the type of the interface. A value from the enumerationInterfaceType
must be used, e.g.,Wired
,Wireless
,Loopback
, orUnknown
. The type of an interface defaults toInterfaceType.Unknown
.
add_file(filename, text)
It is sometimes necessary to make sure that certain text files are available during a test that a user program expects, e.g., a static forwarding table for an IP router. This method can be used to specify that a file with the name
filename
and with contentstext
should be written to the current directory when the test scenario is run.
There is one method that creates a new test expectation in the test scenario:
expect(expectation_object, description)
This method adds a new expected event to the test scenario. The first parameter must be an object of type
PacketInputEvent
,PacketInputTimeoutEvent
, orPacketOutputEvent
(each described below). The order in which expectations are added to a test scenario is critical: be certain that they’re added in the right order for the test you want to accomplish!The description parameter is a short text description of what this test step is designed to accomplish. In
swyard
test output, this description is what is printed for each step in both the abbreviated and verbose output: make sure it is descriptive enough so that the purpose of the test can be easily understood. At the same time, try to keep the text short so that it isn’t overwhelming to a reader.
The three event classes set up the specific expectations for each test, as described next.
PacketInputEvent(portname, packet, display=None, copyfromlastout=None)
Create an expectation that a particular packet will arrive and be received on a port named
portname
. The packet must be an instance of the SwitchyardPacket
class. Theportname
is just a string likeeth0
. This port/interface must have previously be configured in the test scenario using the methodadd_interface
(see above).The
display
argument indicates whether a particular header in the packet should be emphasized on output when Switchyard shows test output to a user. By default, all headers are shown. If a test creator wants to ignore the Ethernet header but emphasize the IPv4 header, he/she could use the argumentdisplay=IPv4
. That is, the argument is just the class name of the packet header to be emphasized.The
copyfromlastout
argument can be used to address the situation in which a test scenario author wants to construct an incoming packet (that will be received byrecv_packet
) which has the same values in some packet header fields as the most recent packet emitted. For example, when creating a protocol stack, an application (socket) program might emit a packet with a source port number assigned by the socket emulation module. The destination port number in an arriving packet needs to be the same as the packet that was previously emitted in order for it to be handed to the correct application program. Thus, thecopyfromlastout
can be used to copy one or more packet header attributes from the last emitted packet to header fields in an incoming packet.
copyfromlastout
can take a tuple of 5 elements: the interface/port name out which the packet was sent, a header class name and attribute to copy from, and a header class name and attribute to copy to. For example, if we wanted to copy the UDP source port value from the last packet emitted out porten1
to the UDP destination port of the packet to be received, we could use the following:PacketInputEvent('en1', pkt, copyfromlastout('en1', UDP, 'src', UDP, 'dst'))Note that we would need to have created a
Packet
object namedpkt
which included a UDP header for this example to work correctly.
PacketInputTimeoutEvent(timeout)
Create an expectation that the Switchyard user program will call
recv_packet
but time out prior to receiving anything. The timeout value is the number of seconds to wait within the test framework before raising theNoPackets
exception in the user code. In order for this test expectation to pass, the user code must correctly handle the exception and must not emit a packet.To force a
NoPackets
exception, the timeout value given to this event must be greater than the timeout value used in a call torecv_packet
. Note also that the test framework will pause for the entire duration of the given timeout. If a user program callsnet.recv_packet(timeout=1.0)
but the timeout given for aPacketInputTimeoutEvent
is 5 seconds, the call torecv_packet
will appear to have blocked for 5 seconds, not 1.
PacketOutputEvent(*args, display=None, exact=True, predicates=[], wildcard=[])
Create an expectation that the user program will emit packets out one or more ports/interfaces. The only required arguments are
args
, which must be an even number of arguments. For each pair of arguments given, the first is a port name (e.g.,en0
) and the second is a reference to a packet object. Normally, a test wishes to establish that the same packet has been emitted out multiple interfaces. To do that, you could simply write:p = Packet() # fill in some packet headers ... PacketOutputEvent('en0', pkt, 'en1', pkt, 'en2', pkt)The above code expects that the same packet (named
pkt
) will be emitted out three interfaces (en0
,en1
, anden2
).By default, the PacketOutputEvent class looks for an exact match between the reference packet supplied to PacketOutputEvent and the packet that the user code actually emits. In some cases, this isn’t appropriate or even possible. For example, you may want to verify that packets are forwarded correctly using standard IP (longest prefix match) forwarding rules, but you may not know the payload contents of a packet because another test element may modify them. As another example, in IP forwarding you know that the TTL (time-to-live) should be decremented by one, but the specific value in an outgoing packet depends on the value on the incoming packet, which the test framework may not know in advance. To handle these situations, you can supply
exact
,wildcard(s)
, and/orpredicate(s)
keyword arguments, as detailed below.
Exact vs. subset matching: Setting
exact
toTrue
orFalse
determines whether all packet header attributes are compared (exact=True
) or whether a limited subset are compared (exact=False
).The set of header fields that are compared when
exact=False
is specified are: Ethernet source and destination addresses, Ethernet ethertype field, Vlan vlanid and ethertype field, ARP target and sender protocol and hardware addresses (four fields), IPv4/IPv6 source and destination addresses and protocol, and TCP/UDP src/dst port numbers (or ICMP/ICMPv6 icmptype/icmpcode fields). Note that in subset matching no packet payloads are compared.Wildcard fields: In addition to specifying the
exact
keyword parameter, it is possible to specify that some additional header fields should be wildcarded. That is, the wildcarded header fields are allowed to contain any value. Wildcards are specified using a tuple of two elements: a header class name and a field name.A single wildcard can be supplied (i.e., one 2-tuple) with the
wildcard
keyword parameter, or a list of 2-tuples can be supplied with thewildcards
keyword. For example, the following line of code uses subset matching (exact=False
) and one wildcard. For this example, assume that the packetpkt
containsEthernet
,IPv4
, andUDP
headers:PacketOutputEvent('en0', pkt, exact=False, wildcard=(IPv4, 'src'))Note that for the above example, the only fields compared in the IPv4 header would be the destination address and protocol field (since other fields are already ignored with
exact=False
).Here is another example that ignores source addresses in the Ethernet, IPv4 and UDP fields, leaving only two fields in the Ethernet header to be compared (dst and ethertype), two fields to be compared in the IPv4 header (dst and protocol) and one field in UDP (dst). Again, assume that the packet
pkt
containsEthernet
,IPv4
, andUDP
headers:PacketOutputEvent('en0', pkt, exact=False, wildcards=[(Ethernet, 'src'), (IPv4, 'src'), (UDP, 'src')])Note
Switchyard previously allowed certain strings (modeled on the Openflow 1.0 specification) to be used to indicate wildcarded fields. These strings can no longer be used in the current version of Switchyard. To specify wildcarded fields, you must use the
(hdrclass, attribute)
syntax.
Predicate functions: Lastly, predicate functions can be supplied to make arbitrary tests against packets. The
predicate
keyword argument can take a singlelambda
function in the form of a string, and thepredicates
keyword argument can take a list oflambda
functions, each as strings. Each lambda given must take a single argument (the packet object to be inspected) and must yield a boolean value. (Note that internally, each lambda definition iseval
'ed by Switchyard.)Here is one example that checks whether the IPv4 ttl field is between 32 and 34, inclusive. Note that this line of code contains a single predicate function as a string:
PacketOutputEvent('en1', pkt, exact=False, predicate='''lambda p: p.has_header(IPv4) and 32 <= p[IPv4].ttl <= 34''')To provide multiple predicates, just use the
predicates
(plural) keyword and provide a list of lambdas-as-strings.
Test scenario examples¶
First, here is an example of a test scenario in which a packet is constructed and is expected to be received on port eth1
, then sent back out the same port, unmodified. Notice in the example that the name scenario
is required.
from switchyard.lib.userlib import *
scenario = TestScenario("in/out test scenario example")
# only one interface on this imaginary device
scenario.add_interface('eth0', 'ab:cd:ef:ab:cd:ef', '1.2.3.4/16',
iftype=InterfaceType.Wired)
# construct a packet to be received
p = Ethernet(src="00:11:22:33:44:55", dst="66:55:44:33:22:11") + \
IPv4(src="1.1.1.1", dst="2.2.2.2", protocol=IPProtocol.UDP) + \
UDP(src=5555, dst=8888) + b'some payload'
# expect that the packet is received
scenario.expect(PacketInputEvent('eth0', p),
"A udp packet should arrive on eth0")
# and expect that the packet is sent right back out
scenario.expect(PacketOutputEvent('eth0', p, exact=True),
"The udp packet should be emitted back out eth0")
Here is an additional example with a bit more complexity. The context for this example might be that we are implementing an IPv4 router. First, notice that we include in the scenario a static forwarding table file (forwarding_table.txt
) to be written out when the scenario is executed. We construct a packet destined to a particular IP address and create an expectation that it arrives on port eth0
. We then construct an expectation that the packet should be forwarded out port eth2
(note that according to the forwarding table, any packets destined to 2.0.0.0/8 should be forwarded out that port). We also include a predicate function to test that the IPv4 ttl is decremented by 1. Note that if we did not include this predicate, any ttl value would be accepted since we have specified exact=False
. Note also that if we had set exact=True
we would almost certainly need to wildcard several fields, e.g., checksums in the IPv4 and UDP headers, and would still need to include a predicate to check that ttl has been properly decremented. Furthermore, if we were writing a test scenario for an IP router, we would also want to include expectations that the correct ARP messages were sent in order to obtain the hardware address corresponding to the next hop IP address.
from switchyard.lib.userlib import *
scenario = TestScenario("packet forwarding example")
# three interfaces on this device
scenario.add_interface('eth0', 'ab:cd:ef:ab:cd:ef', '1.2.3.4/16')
scenario.add_interface('eth1', '00:11:22:ab:cd:ef', '5.6.7.8/16')
scenario.add_interface('eth2', 'ab:cd:ef:00:11:22', '9.10.11.12/24')
# add a forwarding table file to be written out when the test
# scenario is executed
scenario.add_file('forwarding_table.txt', '''
# network subnet-mask next-hop port
2.0.0.0 255.0.0.0 9.10.11.13 eth2
3.0.0.0 255.255.0.0 5.6.100.200 eth1
''')
# construct a packet to be received
p = Ethernet(src="00:11:22:33:44:55", dst="66:55:44:33:22:11") + \
IPv4(src="1.1.1.1", dst="2.2.2.2", protocol=IPProtocol.UDP, ttl=61) + \
UDP(src=5555, dst=8888) + b'some payload'
# expect that the packet is received
scenario.expect(PacketInputEvent('eth0', p),
"A udp packet destined to 2.2.2.2 arrives on port eth0")
# and subsequently forwarded out the correct port; employ
# subset (exact=False) matching, along with a check that the
# IPv4 TTL was decremented exactly by 1.
scenario.expect(PacketOutputEvent('eth2', p, exact=False,
predicate='''lambda pkt: pkt.has_header(IPv4) and pkt[IPv4].ttl == 60'''),
"The udp packet destined to 2.2.2.2 should be forwarded out port eth2, with an appropriately decremented TTL.")
Compiling a test scenario¶
A test scenario can be run directly with swyard
or it can be compiled into a form that can be distributed without giving away the code which was used to construct it. Compiled test scenario files are, by default, given a .srpy
extension; uncompiled test scenarios should just be regular Python (.py
) files.
To compile a test scenario, you can simply invoke swyard
with the -c
flag, as follows:
swyard -c code/testscenario2.py
The output from this command should be a new file named code/testscenario2.srpy
containing the obfuscated test scenario. This file can be used as the argument to the -t
option when later running a Switchyard program against those tests.
Note
Note that if a scenario is compiled using a different version of Python than the one used to run a test scenario (especially a different major version, e.g., 3.4 vs. 3.5), you may get some mysterious errors. The errors are due to the fact that serialized representations of Python objects may change from one version to the next; if there are any changes, then the version used to run the test cannot correctly deserialize the various objects stored in the test scenario.