Mocking CLIs with
Python/Unittest
A lean mock approach for DevOps
Presenter: Song.Jin (DevOps Engineer)
Replicant Crew
Platform-Enablement Tribe
MYOB
21 Jun 2018
Outline
• Why
• What
• How
Why do we care about testing ?
• Shorter feedback loop:
• Testing on built AMI takes loooong time to complete
• Some services are impossible to test locally, such as
etcdctl and curl on endpoints only available on AMIs.
• Code becomes too complex to test manually
• Tests do help with separating concerns for a better code
Why Python/unittest
• Our journey
• Bash
• BATS
• Shunit
• Bash-conclusion: too painful (due to buggy testing framework)!
Why Python/Unittest
• Our Journey (continue)
• We decided to up-gun a LITTLE bit with proper
programming language.
• Python has got mature community
• More people know about it for a long time = teamwork
+ easier to find answer to problems on Internet
• Python has got a working test-framework. full-stop!
What (A/C)
• External calls mocked = [AWScli, curl, etcdctl]
• We expect:
• when I call the CLIs from Python subprocess, tests
should NOT do actual calls, but return to me pre-defined
responses. a.k.a “Contracts”
• Tests should fail if our code malfunctions.
• Fast reproducible test results.
How
• Use Python/Unittest/MagicMock. Keep it simple, ya?
def get_instance_id():
instance_id = curl('-s http://169.254.169.254/latest/meta-data/instance-id')
return instance_id
The Business Logic
Plumbings - curl AWS etcdctl
def curl(url):
return exec_sh("curl {}".format(url))
def aws(args):
return exec_sh("aws {}".format(args))
def etcdctl(args):
etcdctl_env = {}
try:
etcdctl_env["ETCDCTL_API"] = get_env_var("ETCDCTL_API")
except KeyError:
print "ETCDCTL_API didn't set, using default ver: 3"
etcdctl_env["ETCDCTL_API"] = "3"
return exec_sh("etcdctl {}".format(args),env=etcdctl_env)
Plumbings - run a Bash
def exec_sh(cmd, env={}):
output = ""
try:
p = Popen(
cmd,
shell=True,
stdout=PIPE,
stderr=STDOUT,
env=env
)
output = p.communicate()[0].strip()
except CalledProcessError as err:
raise ShellError(err.returncode,err.output.strip())
return output
class TestEtcdInitCluster(unittest.TestCase):
# The expectation
CURL_AWS_METADATA = {
'-s http://169.254.169.254/latest/dynamic/instance-identity/document': json.dumps({"region": "ap-southeas
'-s http://169.254.169.254/latest/meta-data/instance-id': 'i-node01',
}
@mock.patch(‘boot.etcd_init_cluster.curl') # telling it “curl” to be mocked (aka “hijacked for tests”)
def test_get_instance_id(self, curl_mock): # now load in the MagicMock object
def curl_fn(args):
response = TestEtcdInitCluster.CURL_AWS_METADATA[args]
curl_mock.side_effect = curl_fn # replace real curl function with “curl_fn”.
result = get_instance_id() # mock to find mapping from curl args to response value
self.assertEqual(result, “i-node01”) # profit!! Verify response from mocked function
Tests:
potential challenges
• test discovery
• “python -m unittest discover -s <your-test-folder>”
• already very easy to setup, compare with Bash test
frameworks
• sequence to load in Mock objects matters!
@mock.patch(‘foo.bar.aws’, side_effect=hello)
@mock.patch(‘foo.bar.write_file', side_effect=blah)
def test_foo.bar(self, write_file_mock, aws_mock):
pass
potential challenges
Questions?

Mock cli with Python unittest

  • 1.
    Mocking CLIs with Python/Unittest Alean mock approach for DevOps Presenter: Song.Jin (DevOps Engineer) Replicant Crew Platform-Enablement Tribe MYOB 21 Jun 2018
  • 2.
  • 3.
    Why do wecare about testing ? • Shorter feedback loop: • Testing on built AMI takes loooong time to complete • Some services are impossible to test locally, such as etcdctl and curl on endpoints only available on AMIs. • Code becomes too complex to test manually • Tests do help with separating concerns for a better code
  • 4.
    Why Python/unittest • Ourjourney • Bash • BATS • Shunit • Bash-conclusion: too painful (due to buggy testing framework)!
  • 5.
    Why Python/Unittest • OurJourney (continue) • We decided to up-gun a LITTLE bit with proper programming language. • Python has got mature community • More people know about it for a long time = teamwork + easier to find answer to problems on Internet • Python has got a working test-framework. full-stop!
  • 6.
    What (A/C) • Externalcalls mocked = [AWScli, curl, etcdctl] • We expect: • when I call the CLIs from Python subprocess, tests should NOT do actual calls, but return to me pre-defined responses. a.k.a “Contracts” • Tests should fail if our code malfunctions. • Fast reproducible test results.
  • 7.
  • 8.
    def get_instance_id(): instance_id =curl('-s http://169.254.169.254/latest/meta-data/instance-id') return instance_id The Business Logic
  • 9.
    Plumbings - curlAWS etcdctl def curl(url): return exec_sh("curl {}".format(url)) def aws(args): return exec_sh("aws {}".format(args)) def etcdctl(args): etcdctl_env = {} try: etcdctl_env["ETCDCTL_API"] = get_env_var("ETCDCTL_API") except KeyError: print "ETCDCTL_API didn't set, using default ver: 3" etcdctl_env["ETCDCTL_API"] = "3" return exec_sh("etcdctl {}".format(args),env=etcdctl_env)
  • 10.
    Plumbings - runa Bash def exec_sh(cmd, env={}): output = "" try: p = Popen( cmd, shell=True, stdout=PIPE, stderr=STDOUT, env=env ) output = p.communicate()[0].strip() except CalledProcessError as err: raise ShellError(err.returncode,err.output.strip()) return output
  • 11.
    class TestEtcdInitCluster(unittest.TestCase): # Theexpectation CURL_AWS_METADATA = { '-s http://169.254.169.254/latest/dynamic/instance-identity/document': json.dumps({"region": "ap-southeas '-s http://169.254.169.254/latest/meta-data/instance-id': 'i-node01', } @mock.patch(‘boot.etcd_init_cluster.curl') # telling it “curl” to be mocked (aka “hijacked for tests”) def test_get_instance_id(self, curl_mock): # now load in the MagicMock object def curl_fn(args): response = TestEtcdInitCluster.CURL_AWS_METADATA[args] curl_mock.side_effect = curl_fn # replace real curl function with “curl_fn”. result = get_instance_id() # mock to find mapping from curl args to response value self.assertEqual(result, “i-node01”) # profit!! Verify response from mocked function Tests:
  • 12.
    potential challenges • testdiscovery • “python -m unittest discover -s <your-test-folder>” • already very easy to setup, compare with Bash test frameworks
  • 13.
    • sequence toload in Mock objects matters! @mock.patch(‘foo.bar.aws’, side_effect=hello) @mock.patch(‘foo.bar.write_file', side_effect=blah) def test_foo.bar(self, write_file_mock, aws_mock): pass potential challenges
  • 14.