← Back to team overview

yellow team mailing list archive

[Merge] lp:~frankban/lpsetup/commands-unittests into lp:lpsetup

 

Francesco Banconi has proposed merging lp:~frankban/lpsetup/commands-unittests into lp:lpsetup.

Requested reviews:
  Yellow Squad (yellow)
Related bugs:
  Bug #1017426 in lpsetup: "lpsetup: subcommands unit tests (steps and handlers)"
  https://bugs.launchpad.net/lpsetup/+bug/1017426

For more details, see:
https://code.launchpad.net/~frankban/lpsetup/commands-unittests/+merge/112094

== Changes ==

sub command tests: added unit tests for the get, inithost, install and lxcinstall sub commands. Each TestCase is a subclass of `tests.utils.StepsBasedSubCommandTestMixin`: this way handlers, steps and needs_root are automatically tested in a declarative way. Note that arguments parsing is also tested for each sub command: see *StepsBasedSubCommandTestMixin* doctest for a more detailed explanation.
Note that update and branch sub commands are not tested: they are obsolete and will either go away or be heavily modified before the end of the process.

argparser.StepsBasedSubCommand: the steps are no longer handled by *__init__*. Added a new *get_steps* method returning a list of *(step_name, step_callable, step_args)* tuples. This way 1) it is easier for a sub command subclassing another command to dynamically modify steps, and 2) we have a single method to call if we want to check the steps that a sub command wants to run.

argparser.StepsBasedSubCommand: added *_include_step* to decouple steps filtering from command execution. Now we have a single method that handle `--skip-steps` or `--steps` options, and the inclusion/exclusion is performed when the steps are retrieved and not when the sub command runs (*subcommand.handle()*).

get: removed *needs_root* to avoid confusion: *needs_root* is unnecessary since the command overrides *get_needs_root()*.

lxcinstall: now the command subclasses install and reuses inithost, install and get steps. A consequence of this change is that now the command correctly handles `directory` and `dependencies_dir` arguments.

tests.utils.SubCommandTestMixin: implemented a *parse* method that parsed command line arguments and returns an initialized namespace. Changed *parse_and_call_main* to reuse the *parse* method and to accept variable positional arguments.

tests.utils: added a helper function returning a random string.

tests.test_argparser: added a test for namespace initialization.
-- 
https://code.launchpad.net/~frankban/lpsetup/commands-unittests/+merge/112094
Your team Yellow Squad is requested to review the proposed merge of lp:~frankban/lpsetup/commands-unittests into lp:lpsetup.
=== modified file 'lpsetup/argparser.py'
--- lpsetup/argparser.py	2012-03-30 11:02:11 +0000
+++ lpsetup/argparser.py	2012-06-26 12:40:25 +0000
@@ -416,15 +416,15 @@
 
     steps = ()
 
-    def __init__(self, *args, **kwargs):
-        super(StepsBasedSubCommand, self).__init__(*args, **kwargs)
-        self._step_names = []
-        self._steps = {}
-        for step_args in self.steps:
-            step, args = step_args[0], step_args[1:]
-            step_name = self._get_step_name(step)
-            self._step_names.append(step_name)
-            self._steps[step_name] = (step, args)
+    def add_arguments(self, parser):
+        super(StepsBasedSubCommand, self).add_arguments(parser)
+        step_names = [self._get_step_name(i[0]) for i in self.steps]
+        parser.add_argument(
+            '-s', '--steps', nargs='+', choices=step_names,
+            help='Call one or more internal functions.')
+        parser.add_argument(
+            '--skip-steps', nargs='+', choices=step_names,
+            help='Skip one or more internal functions.')
 
     def _get_step_name(self, step):
         """Return the string representation of a step callable.
@@ -450,28 +450,37 @@
         except AttributeError:
             return step.__name__
 
-    def add_arguments(self, parser):
-        super(StepsBasedSubCommand, self).add_arguments(parser)
-        parser.add_argument(
-            '-s', '--steps', nargs='+', choices=self._step_names,
-            help='Call one or more internal functions.')
-        parser.add_argument(
-            '--skip-steps', nargs='+', choices=self._step_names,
-            help='Skip one or more internal functions.')
+    def _include_step(self, step_name, namespace):
+        """Return True if the given *step_name* must be run, False otherwise.
+
+        A step is included in the command execution if the step is not
+        skipped using either `--skip-steps` or `--steps`.
+        """
+        steps_to_skip = namespace.skip_steps or []
+        steps_to_run = namespace.steps
+        if step_name not in steps_to_skip:
+            return step_name in steps_to_run if steps_to_run else True
+        return False
+
+    def get_steps(self, namespace):
+        """Return a list of *(step_name, step_callable, step_args)* tuples."""
+        steps = []
+        for step_arg_names in self.steps:
+            step, arg_names = step_arg_names[0], step_arg_names[1:]
+            step_name = self._get_step_name(step)
+            if self._include_step(step_name, namespace):
+                args = [getattr(namespace, i) for i in arg_names]
+                steps.append((step_name, step, args))
+        return steps
 
     def _call_step(self, namespace, step, args):
         """Default callable used to run a `step`, using given `args`."""
         return step(*args)
 
     def handle(self, namespace):
-        skip_steps = namespace.skip_steps or []
-        step_names = filter(
-            lambda step_name: step_name not in skip_steps,
-            namespace.steps or self._step_names)
         default_step_runner = self._call_step
-        for step_name in step_names:
-            step, arg_names = self._steps[step_name]
-            args = [getattr(namespace, i) for i in arg_names]
+        for step_name, step, args in self.get_steps(namespace):
+            # Run the step using a dynamic dispatcher.
             step_runner = getattr(
                 self, 'call_' + step_name, default_step_runner)
             try:

=== modified file 'lpsetup/subcommands/get.py'
--- lpsetup/subcommands/get.py	2012-06-25 19:24:56 +0000
+++ lpsetup/subcommands/get.py	2012-06-26 12:40:25 +0000
@@ -158,7 +158,6 @@
         )
 
     help = __doc__
-    needs_root = True
     validators = (
         handlers.handle_user,
         handlers.handle_lpuser,

=== modified file 'lpsetup/subcommands/install.py'
--- lpsetup/subcommands/install.py	2012-06-22 20:50:41 +0000
+++ lpsetup/subcommands/install.py	2012-06-26 12:40:25 +0000
@@ -99,11 +99,13 @@
     """Install the Launchpad environment."""
 
     # The steps for "install" are a superset of the steps for "inithost".
+    setup_bzr_locations_step = (setup_bzr_locations,
+         'user', 'lpuser', 'directory')
+
     steps = (
         inithost.SubCommand.initialize_step,
         get.SubCommand.fetch_step,
-        (setup_bzr_locations,
-         'user', 'lpuser', 'directory'),
+        setup_bzr_locations_step,
         inithost.SubCommand.setup_apt_step,
         (setup_launchpad,
          'user', 'dependencies_dir', 'directory', 'valid_ssh_keys'),

=== modified file 'lpsetup/subcommands/lxcinstall.py'
--- lpsetup/subcommands/lxcinstall.py	2012-06-25 17:55:10 +0000
+++ lpsetup/subcommands/lxcinstall.py	2012-06-26 12:40:25 +0000
@@ -43,6 +43,7 @@
     SCRIPTS,
     )
 from lpsetup.subcommands import (
+    get,
     inithost,
     install,
     )
@@ -201,16 +202,13 @@
         subprocess.call(['lxc-stop', '-n', lxc_name])
 
 
-class SubCommand(inithost.SubCommand):
+class SubCommand(install.SubCommand):
     """Install the Launchpad environment inside an LXC."""
 
     steps = (
-        (inithost.initialize,
-         'user', 'full_name', 'email', 'lpuser',
-         'private_key', 'public_key', 'valid_ssh_keys', 'ssh_key_path',
-         'feed_random', 'dependencies_dir', 'directory'),
-        (install.setup_bzr_locations,
-         'user', 'lpuser', 'directory'),
+        inithost.SubCommand.initialize_step,
+        get.SubCommand.fetch_step,
+        install.SubCommand.setup_bzr_locations_step,
         (create_scripts,
          'user', 'lxc_name', 'ssh_key_path'),
         (create_lxc,
@@ -231,8 +229,7 @@
 
     def get_validators(self, namespace):
         validators = super(SubCommand, self).get_validators(namespace)
-        return validators + (
-            handlers.handle_testing, handlers.handle_directories)
+        return validators + (handlers.handle_testing,)
 
     def call_create_scripts(self, namespace, step, args):
         """Run the `create_scripts` step only if the related flag is set."""
@@ -257,7 +254,7 @@
         parser.add_argument(
             '-C', '--create-scripts', action='store_true',
             help='Create the scripts used by buildbot for parallel testing.')
-        # The following flag is not present in the install sub command since
+        # The following flag is not present in the inithost sub command since
         # subunit is always installed there as a dependency of
         # launchpad-developer-dependencies.
         parser.add_argument(

=== added file 'lpsetup/tests/subcommands/test_get.py'
--- lpsetup/tests/subcommands/test_get.py	1970-01-01 00:00:00 +0000
+++ lpsetup/tests/subcommands/test_get.py	2012-06-26 12:40:25 +0000
@@ -0,0 +1,66 @@
+#!/usr/bin/env python
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the get sub command."""
+
+import unittest
+
+from lpsetup import handlers
+from lpsetup.subcommands import get
+from lpsetup.tests.utils import (
+    get_random_string,
+    StepsBasedSubCommandTestMixin,
+    )
+
+
+fetch_step = (
+    get.fetch, ['user', 'directory', 'dependencies_dir', 'valid_ssh_keys'])
+update_launchpad_step = (
+    get.update_launchpad, ['user', 'dependencies_dir', 'directory',
+    'make_schema', 'apt'])
+link_sourcecode_in_branches_step = (
+    get.link_sourcecode_in_branches, ['user', 'dependencies_dir',
+    'directory'])
+
+
+def get_update_arguments():
+    user = get_random_string()
+    directory = '~/' + get_random_string()
+    dependencies_dir = '~/' + get_random_string()
+    ssh_key_name = get_random_string()
+    return (
+        '-u', user, '-c', directory, '--make-schema',
+        '-d', dependencies_dir, '-S', ssh_key_name,
+        )
+
+
+def get_arguments():
+    email = get_random_string()
+    full_name = get_random_string() + '@example.com'
+    lpuser = get_random_string()
+    private_key = get_random_string()
+    public_key = get_random_string()
+    return get_update_arguments() + (
+        '-e', email, '-f', full_name, '-l', lpuser,
+        '-v', private_key, '-b', public_key,
+        '--no-repositories', '--feed-random')
+
+
+class GetTest(StepsBasedSubCommandTestMixin, unittest.TestCase):
+
+    sub_command_class = get.SubCommand
+    expected_arguments = get_arguments()
+    expected_handlers = (
+        handlers.handle_user,
+        handlers.handle_lpuser,
+        handlers.handle_userdata,
+        handlers.handle_ssh_keys,
+        handlers.handle_directories,
+        )
+    expected_steps = (
+        fetch_step,
+        update_launchpad_step,
+        link_sourcecode_in_branches_step,
+        )
+    needs_root = False

=== added file 'lpsetup/tests/subcommands/test_inithost.py'
--- lpsetup/tests/subcommands/test_inithost.py	1970-01-01 00:00:00 +0000
+++ lpsetup/tests/subcommands/test_inithost.py	2012-06-26 12:40:25 +0000
@@ -0,0 +1,50 @@
+#!/usr/bin/env python
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the inithost sub command."""
+
+import unittest
+
+from lpsetup import handlers
+from lpsetup.subcommands import inithost
+from lpsetup.tests.utils import (
+    get_random_string,
+    StepsBasedSubCommandTestMixin,
+    )
+
+
+initialize_step = (
+    inithost.initialize, ['user', 'full_name', 'email', 'lpuser',
+    'private_key', 'public_key', 'valid_ssh_keys', 'ssh_key_path',
+    'feed_random'])
+setup_apt_step = (inithost.setup_apt, ['no_repositories'])
+
+
+def get_arguments():
+    user = get_random_string()
+    email = get_random_string()
+    full_name = get_random_string() + '@example.com'
+    lpuser = get_random_string()
+    private_key = get_random_string()
+    public_key = get_random_string()
+    ssh_key_name = get_random_string()
+    return (
+        '-u', user, '-e', email, '-f', full_name, '-l', lpuser,
+        '-v', private_key, '-b', public_key, '-S', ssh_key_name)
+
+
+class InithostTest(StepsBasedSubCommandTestMixin, unittest.TestCase):
+
+    sub_command_class = inithost.SubCommand
+    expected_arguments = get_arguments()
+    expected_handlers = (
+        handlers.handle_user,
+        handlers.handle_lpuser,
+        handlers.handle_userdata,
+        handlers.handle_ssh_keys,
+        )
+    expected_steps = (initialize_step, setup_apt_step)
+    needs_root = True
+
+

=== added file 'lpsetup/tests/subcommands/test_install.py'
--- lpsetup/tests/subcommands/test_install.py	1970-01-01 00:00:00 +0000
+++ lpsetup/tests/subcommands/test_install.py	2012-06-26 12:40:25 +0000
@@ -0,0 +1,55 @@
+#!/usr/bin/env python
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the install sub command."""
+
+import unittest
+
+from lpsetup import handlers
+from lpsetup.subcommands import install
+from lpsetup.tests.subcommands import (
+    test_get,
+    test_inithost,
+    )
+from lpsetup.tests.utils import (
+    get_random_string,
+    StepsBasedSubCommandTestMixin,
+    )
+
+
+setup_bzr_locations_step = (
+    install.setup_bzr_locations, ['user', 'lpuser', 'directory'])
+setup_launchpad_step = (
+    install.setup_launchpad, ['user', 'dependencies_dir', 'directory',
+    'valid_ssh_keys'])
+
+
+def get_arguments():
+    inithost_arguments = test_inithost.get_arguments()
+    dependencies_dir = '~/' + get_random_string()
+    directory = '~/' + get_random_string()
+    return inithost_arguments + ('-d', dependencies_dir, '-c', directory)
+
+
+class InstallTest(StepsBasedSubCommandTestMixin, unittest.TestCase):
+
+    sub_command_class = install.SubCommand
+    expected_arguments = get_arguments()
+    expected_handlers = (
+        handlers.handle_user,
+        handlers.handle_lpuser,
+        handlers.handle_userdata,
+        handlers.handle_ssh_keys,
+        handlers.handle_directories,
+        )
+    expected_steps = (
+        test_inithost.initialize_step,
+        test_get.fetch_step,
+        setup_bzr_locations_step,
+        test_inithost.setup_apt_step,
+        setup_launchpad_step,
+        )
+    needs_root = True
+
+

=== added file 'lpsetup/tests/subcommands/test_lxcinstall.py'
--- lpsetup/tests/subcommands/test_lxcinstall.py	1970-01-01 00:00:00 +0000
+++ lpsetup/tests/subcommands/test_lxcinstall.py	2012-06-26 12:40:25 +0000
@@ -0,0 +1,75 @@
+#!/usr/bin/env python
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the lxcinstall sub command."""
+
+import random
+import unittest
+
+from lpsetup import (
+    handlers,
+    settings,
+    )
+from lpsetup.subcommands import lxcinstall
+from lpsetup.tests.subcommands import (
+    test_get,
+    test_inithost,
+    test_install,
+    )
+from lpsetup.tests.utils import (
+    get_random_string,
+    StepsBasedSubCommandTestMixin,
+    )
+
+
+create_scripts_step = (
+    lxcinstall.create_scripts, ['user', 'lxc_name', 'ssh_key_path'])
+create_lxc_step = (
+    lxcinstall.create_lxc, ['user', 'lxc_name', 'lxc_arch', 'lxc_os',
+    'install_subunit'])
+start_lxc_step = (lxcinstall.start_lxc, ['lxc_name'])
+wait_for_lxc_step = (lxcinstall.wait_for_lxc, ['lxc_name', 'ssh_key_path'])
+initialize_lxc_step = (
+    lxcinstall.initialize_lxc, ['lxc_name', 'ssh_key_path', 'lxc_os'])
+setup_launchpad_lxc_step = (
+    lxcinstall.setup_launchpad_lxc, ['user', 'dependencies_dir', 'directory',
+    'valid_ssh_keys', 'ssh_key_path', 'lxc_name'])
+stop_lxc_step = (lxcinstall.stop_lxc, ['lxc_name', 'ssh_key_path'])
+
+
+def get_arguments():
+    lxc_name = get_random_string()
+    lxc_arch = random.choice(['i386', 'amd64'])
+    lxc_os = random.choice(settings.LXC_GUEST_CHOICES)
+    return test_inithost.get_arguments() + (
+        '-n', lxc_name, '-A', lxc_arch, '-r', lxc_os,
+        '--create-scripts', '--install-subunit', '--testing'
+        )
+
+
+class LxcInstallTest(StepsBasedSubCommandTestMixin, unittest.TestCase):
+
+    sub_command_class = lxcinstall.SubCommand
+    expected_arguments = get_arguments()
+    expected_handlers = (
+        handlers.handle_user,
+        handlers.handle_lpuser,
+        handlers.handle_userdata,
+        handlers.handle_ssh_keys,
+        handlers.handle_directories,
+        handlers.handle_testing,
+        )
+    expected_steps = (
+        test_inithost.initialize_step,
+        test_get.fetch_step,
+        test_install.setup_bzr_locations_step,
+        create_scripts_step,
+        create_lxc_step,
+        start_lxc_step,
+        wait_for_lxc_step,
+        initialize_lxc_step,
+        setup_launchpad_lxc_step,
+        stop_lxc_step,
+        )
+    needs_root = True

=== modified file 'lpsetup/tests/subcommands/test_version.py'
--- lpsetup/tests/subcommands/test_version.py	2012-05-22 09:36:57 +0000
+++ lpsetup/tests/subcommands/test_version.py	2012-06-26 12:40:25 +0000
@@ -17,7 +17,6 @@
 class VersionTest(SubCommandTestMixin, unittest.TestCase):
 
     sub_command_class = version.SubCommand
-    sub_command_name = 'subcmd'
 
     def test_sub_command(self):
         with capture_output() as output:

=== modified file 'lpsetup/tests/test_argparser.py'
--- lpsetup/tests/test_argparser.py	2012-05-22 09:36:57 +0000
+++ lpsetup/tests/test_argparser.py	2012-06-26 12:40:25 +0000
@@ -96,7 +96,7 @@
 
     def test_arguments(self):
         # Ensure the sub command arguments are correctly handled.
-        namespace = self.parse_and_call_main('--foo eggs')
+        namespace = self.parse_and_call_main('--foo', 'eggs')
         self.assertEqual('eggs', namespace.foo)
 
     def test_successful_validation(self):
@@ -121,6 +121,12 @@
         self.assertIn(self.sub_command.name, help)
         self.assertIn(self.sub_command.help, help)
 
+    def test_init_namespace(self):
+        # The namespace is initialized with current user info.
+        namespace = self.parse()
+        self.assertIsInstance(namespace.euid, int)
+        self.assertIsInstance(namespace.run_as_root, bool)
+
 
 class StepsBasedSubCommandTest(SubCommandTestMixin, unittest.TestCase):
 
@@ -129,23 +135,23 @@
     def test_steps(self):
         # Ensure steps are executed in the order they are provided.
         with capture_output() as output:
-            self.parse_and_call_main('--foo eggs --bar spam')
+            self.parse_and_call_main('--foo', 'eggs', '--bar', 'spam')
         self.check_output(
             ['step1 received eggs', 'step2 received eggs and spam'],
             output)
 
-    def test_step_flag(self):
+    def test_steps_flag(self):
         # A special argument `-s` or `--steps` is automatically added to the
         # parser. It can be used to execute only one or a subset of steps.
         with capture_output() as output:
-            self.parse_and_call_main('--foo eggs -s step1')
+            self.parse_and_call_main('--foo', 'eggs', '-s', 'step1')
         self.check_output(['step1 received eggs'], output)
 
-    def test_skip_steps(self):
+    def test_skip_steps_flag(self):
         # A special argument `--skip-steps` is automatically added to the
         # parser. It can be used to skip one or more steps.
         with capture_output() as output:
-            self.parse_and_call_main('--foo eggs --skip-steps step1')
+            self.parse_and_call_main('--foo', 'eggs', '--skip-steps', 'step1')
         self.check_output(['step2 received eggs and None'], output)
 
     def test_step_name(self):
@@ -164,7 +170,7 @@
         # Ensure the steps execution is stopped if a step raises
         # `subprocess.CalledProcessError`.
         with capture_output() as output:
-            error = self.parse_and_call_main('--foo eggs')
+            error = self.parse_and_call_main('--foo', 'eggs')
         self.assertEqual(1, error.returncode)
         self.check_output(['step1 received eggs'], output)
 
@@ -177,7 +183,7 @@
         # The test runner calls a function named 'call_[step name]' if it is
         # defined.
         with capture_output() as output:
-            self.parse_and_call_main('--foo eggs --bar spam')
+            self.parse_and_call_main('--foo', 'eggs', '--bar', 'spam')
         expected = [
             'running step1 with eggs while bar is spam',
             'step1 received eggs',

=== modified file 'lpsetup/tests/utils.py'
--- lpsetup/tests/utils.py	2012-05-22 09:36:57 +0000
+++ lpsetup/tests/utils.py	2012-06-26 12:40:25 +0000
@@ -8,10 +8,20 @@
 __all__ = [
     'capture_error',
     'capture_output',
+    'get_random_string',
+    'StepsBasedSubCommandTestMixin',
+    'SubCommandTestMixin',
     ]
 
 from contextlib import contextmanager
 from functools import partial
+from itertools import (
+    imap,
+    islice,
+    repeat,
+    )
+import random
+import string
 from StringIO import StringIO
 import sys
 
@@ -34,23 +44,87 @@
 capture_error = partial(capture, 'stderr')
 
 
+_random_letters = imap(random.choice, repeat(string.letters + string.digits))
+
+
+def get_random_string(size=10):
+    """Return a random string to be used in tests."""
+    return "".join(islice(_random_letters, size))
+
+
 class SubCommandTestMixin(object):
 
     sub_command_class = examples.SubCommand
     sub_command_name = 'subcmd'
 
     def setUp(self):
+        """Set up an argument parser and instantiate *self.sub_command_class*.
+
+        The name used to create the sub command instance is
+        *self.sub_command_name*.
+        """
         self.parser = argparser.ArgumentParser()
         self.sub_command = self.parser.register_subcommand(
             self.sub_command_name, self.sub_command_class)
 
-    def parse_and_call_main(self, arguments=None):
-        args = [self.sub_command_name]
-        if arguments is not None:
-            args.extend(arguments.split())
-        namespace = self.parser.parse_args(args)
+    def parse(self, *args):
+        """Parse given *args* and return an initialized namespace object."""
+        namespace = self.parser.parse_args((self.sub_command_name,) + args)
+        sub_command = self.sub_command
+        sub_command.init_namespace(namespace)
+        sub_command.validate(self.parser, namespace)
+        return namespace
+
+    def parse_and_call_main(self, *args):
+        """Create a namespace using the given *args* and invoke main."""
+        namespace = self.parse(*args)
         return namespace.main(namespace)
 
     def check_output(self, expected, output):
         value = filter(None, output.getvalue().split('\n'))
         self.assertSequenceEqual(expected, value)
+
+
+class StepsBasedSubCommandTestMixin(SubCommandTestMixin):
+    """This mixin can be used to test sub commands steps and handlers.
+
+    Real TestCases subclassing this mixin must define:
+
+        - expected_arguments: a sequence of command line arguments
+          used by the current tested sub command
+        - expected_handlers: a sequence of expected handler callables
+        - expected_steps: a sequence of expected *(step_callable, arg_names)*
+        - needs_root: True if this sub command must be run as root
+
+    At this point steps and handlers are automatically tested, and the test
+    case also checks if root is required by the sub command.
+    """
+    expected_arguments = ()
+    expected_handlers = ()
+    expected_steps = ()
+    needs_root = False
+
+    def setUp(self):
+        """Set up a namespace using *self.expected_arguments*."""
+        super(StepsBasedSubCommandTestMixin, self).setUp()
+        self.namespace = self.parse(*self.expected_arguments)
+
+    def test_handlers(self):
+        # Ensure this sub command uses the expected handlers.
+        handlers = self.sub_command.get_validators(self.namespace)
+        self.assertSequenceEqual(self.expected_handlers, handlers)
+
+    def test_steps(self):
+        # Ensure this sub command wants to run the expected steps.
+        steps = self.sub_command.get_steps(self.namespace)
+        real_steps = [[step, list(args)] for _, step, args in steps]
+        expected_steps = []
+        for step, arg_names in self.expected_steps:
+            args = [getattr(self.namespace, name) for name in arg_names]
+            expected_steps.append([step, args])
+        self.assertListEqual(expected_steps, real_steps)
+
+    def test_needs_root(self):
+        # The root user may or may not be required to run this sub command.
+        needs_root = self.sub_command.get_needs_root(self.namespace)
+        self.assertEqual(self.needs_root, needs_root)