使用 Vagrant 和 Fabric 用于集成测试
在cloudshare中,我们的服务是由许多部件组成的。当我们更改一个给定组件的代码后我们总需要测试它。我们小心地尝试着平衡单元测试和集成测试(或系统测试)的总量,以便能够实现合理的代码覆盖率和测试运行时间,最重要的是提升对我们代码的信心。
不久前,我们彻底改写了一个叫网关的组件。这个网关运行在Linux机器上,其处理了我们内部的许多路由,防火墙,NAT,负载平衡以及流量日志等很多内容。它基本上是一个路由器/防火墙,通过获取动态配置并根据它了解的配置实施不同的网络规则。这次改写是通过重新设计其(虚拟)硬件和内核模块完成的。它是一个Python应用包使用原始的debian打包部署的。
在重写之前,这个网关是“冰封”的。“冰封”在这里的意思是没有人敢修改它的代码。它没有测试代码,因此每个更改都需要一份完整的手册,单是痛苦的回归测试也需要花一个星期。
我们坐下来定义了我们的目标。我们希望所有的开发人员都能够在本地的机器跑所有的集成测试,并且能够很容易。很容易还意味着在变更代码后不需要部署其他任何东西。需要做的这是在IDE中编辑代码然后重新运行测试。不需要提交代码,不需要重新打包,不需要部署(我们在Windows上开发)/
当进行测试时就不是那么容易了,你知道会发生什么。
改善集成测试:
我们已经知道需要改善我们的单元测试。但是集成测试呢?那是另一回事。你如何测试你的硬件和内核配置以确保这些配置能完成你所想让它实现的网络魔术。
让我们考虑如何手动来做这个事情。简单的方法是用linux提供的一系列网络工具:ping,traceroute,tcpdump,netcat等。事实上,这也正是我们QA工程师做的事情:
部署新代码.
创建一个由几台连接到同一个网关的机器组成的测试平台。
对于任何可能的配置,网关都会测试整个网络的功能应该是流畅/阻塞/跑通NAT/路由等。
这简直是一场噩梦。
我甚至敢说:不要QA工程师,就算他们可能才华横溢,不留死角地涵盖所有情况,或浪费时间(合理数量)。 更何况我们希望的是,他们比只是会弄回归测试多一点创意。 这实际上是机器的工作,而不是人的工作。
我们终于面临了严酷考验,并开始思考为何这是可能的。 Vagrant,那时完全是个新的后备方案,来的如此自然。它允许我们能够创建一个由不同的虚拟局域网连接的虚拟机的环境。 Vagrant 还可以让你直接挂载你在主机文件夹到你管理的虚拟机,并且也满足我们的“容易测试”的要求。 如果代码已经被挂载在VM Vagrant,没必要进行部署。
下面是vagrant 文件, 来定义虚拟环境:
Vagrant::Config.run do |config| config.vm.define :gateway do |gateway_config| gateway_config.vm.box = "gateway" gateway_config.vm.host_name = "gateway" gateway_config.vm.box_url = "http://FQDN…./gateway.box" gateway_config.vm.network :hostonly, "192.168.58.2", { :adapter => 2, :netmask => '255.255.255.0' } gateway_config.vm.network :hostonly, "192.168.56.90", {:adapter => 3, :auto_config => false} gateway_config.vm.share_folder "code", "/code", "../../..", :mount_options => ["dmode=755", "fmode=755"] end config.vm.define :tester1 do |config| config.vm.box = "tester" config.vm.host_name = "tester1" config.vm.box_url = "http://FQDN…../tester.box" config.vm.network :hostonly, "192.168.58.91", {:adapter => 2, :netmask => '255.255.255.0' } config.vm.network :hostonly, "192.168.56.91", {:adapter => 3, :auto_config => false} config.vm.share_folder "code", "/code", "../tests" end config.vm.define :tester2 do |config| config.vm.box = "tester" config.vm.host_name = "tester2" config.vm.box_url = "http://FQDN…./tester.box" config.vm.network :hostonly, "192.168.58.92", {:adapter => 2, :netmask => '255.255.255.0' } config.vm.network :hostonly, "192.168.56.91", {:adapter => 3, :auto_config => false} config.vm.share_folder "code", "/code", "../tests" end … more testers machines defined here ...
如你所见,本地源码呗挂载/编写在vagrant虚拟机中。在这也有网络定义。一个作为集成测试的物理网络用来配置VLANs(注意:auto_confi => false option)和其他用来测试代码通信。
当开发者运行一段测试时发生了什么?
实际上是在网关虚拟机上运行了测试。使用了本地挂载代码来创建应用对象,调用对象,然后使用 fabric在测试机器上远程运行网络工具来ping/sniff/trace/accept 所有通过和返回给网关的流量的种类。
下面来看个简单的例子,简化了很多的:
class TestVlansBase(unittest.TestCase): def setUp(self): self._initialize_tester_machines() def tearDown(self): for tester_name, vlan in self.dct_interfaces_to_remove.iteritems(): self._remove_interface_from_host(tester_name, vlan) class TestVlans(TestVlansBase): def _test_connection( self, server_name, server_vlan, server_ip, server_port, protocol, client_name, client_dst_ip=None, client_dst_port=None): self.assertTrue(protocol in ('tcp', 'udp'), 'protocol should be tcp or udp') client_dst_ip = client_dst_ip or server_ip client_dst_port = client_dst_port or server_port if protocol == 'tcp': server = self._create_server(server_name, server_ip, server_port) elif protocol == 'udp': filter_exp = '{0} port {1}'.format(protocol, server_port) server = self._create_sniffer_on_host(server_name, server_vlan, filter_exp, 1) client = self._connect_to_host(client_name, client_dst_ip, client_dst_port, protocol) client.runner.join() if server.runner.exitcode is None: # this means that the process did not exit hence no packets were seen server.runner.terminate() server.runner.join() self.assertEqual(client.runner.exitcode, 0) self.assertEqual(server.runner.exitcode, 0) def test_reroute_http_traffic(self): self._configure_testers() self.gateway.configure() self._test_connection( 'tester3', 93, '10.180.0.3', 88, 'tcp', 'tester2', client_dst_ip='10.10.10.10', client_dst_port=80) self._test_connection( 'tester3', 93, '10.180.0.3', 88, 'tcp', 'tester2', client_dst_ip='10.10.10.10', client_dst_port=44444) self._test_connection( 'tester3', 93, '10.180.0.3', 88, 'tcp', 'tester2', client_dst_ip='10.10.10.10', client_dst_port=88)
所有的从网关(测试运行的地方)到测试者机器的远程调用使用的是fabric。
一个可以在测试上运行的简单命令:
class FabricProcessProxy(object): __metaclass__ = ABCMeta def __init__(self, *args, **kwargs): self.kwargs = kwargs self.args = args self.out_q = multiprocessing.Queue() # self.runner = multiprocessing.Process(target=lambda: execute(self.run, *self.args, **self.kwargs)) self.runner = multiprocessing.Process( target=lambda: self.out_q.put(execute(self.run, *self.args, **self.kwargs))) def execute(self, hosts): self.kwargs['hosts'] = hosts self.runner.start() return self.runner @abstractmethod def run(self): raise NotImplementedError() class Ping(FabricProcessProxy): def run(self, target, iface, count): str_iface = '-I {0} '.format(iface) if iface else ' ' return run('ping -c {count}{iface}-W 1 {target}'.format(count=count, iface=str_iface, target=target)) def _ping_from_host(self, host, dst_ip, through_iface=None, num_pings=1, b_verify_success=True): ping = Ping(dst_ip, through_iface, num_pings) ping.execute(['vagrant@{0}'.format(host)]).join() if b_verify_success: self.assertEqual(ping.runner.exitcode, 0) return ping.runner.exitcode _ping_from_host('tester2', '10.180.0.3')
既然这个基础结构已经建好了,我们就不在回头看它了。我们今天所拥有的网关是一等公民,而且只要通过了测试,我们就不怕重构它,添加新的功能和做出其他改变。