diff --git a/README.rst b/README.rst
index 89bfb042f71bda49459fdfc7e66041f9f5339007..4b0de7f952c4c87ef2926eaba5e2c4b76d2f902e 100644
--- a/README.rst
+++ b/README.rst
@@ -386,6 +386,91 @@ original function to do its thing. For instance:
         return result
 
 
+Plan a spy operation
+====================
+
+Why start from scratch when setting up a spy? Let's plan an operation.
+
+(Spy operations are only available in KGB 6 or higher.)
+
+
+Raise an exception when called
+------------------------------
+
+.. code-block:: python
+
+   spy_on(pen.emit_poison, op=kgb.SpyOpRaise(PoisonEmptyError()))
+
+
+Or return a value
+-----------------
+
+.. code-block:: python
+
+   spy_on(our_agent.get_identity, op=kgb.SpyOpReturn('nobody...'))
+
+
+Now for something more complicated.
+
+
+Handle a call based on the arguments used
+-----------------------------------------
+
+If you're dealing with many calls to the same function, you may want to return
+different values or only call the original function depending on which
+arguments were passed in the call. That can be done with a ``SpyOpMatchAny``
+operation.
+
+.. code-block:: python
+
+   spy_on(traps.trigger, op=kgb.SpyOpMatchAny([
+       {
+           'args': ('hallway_lasers',),
+           'call_fake': _send_wolves,
+       },
+       {
+           'args': ('trap_tile',),
+           'call_fake': _spill_hot_oil,
+       },
+       {
+           'args': ('infrared_camera',),
+           'kwargs': {
+               'sector': 'underground_passage',
+           },
+           'call_original': False,
+       },
+   ]))
+
+Any unexpected calls will automatically assert.
+
+
+Or require those calls in a specific order
+------------------------------------------
+
+You can combine that with requiring the calls to be in the order you want
+using ``SpyOpMatchInOrder``.
+
+.. code-block:: python
+
+   spy_on(lockbox.enter_code, op=kgb.SpyOpMatchInOrder([
+       {
+           'args': (1, 2, 3, 4, 5, 6),
+           'call_original': False,
+       },
+       {
+           'args': (9, 0, 2, 1, 0, 0),
+           'call_fake': _start_countdown,
+       },
+       {
+           'args': (4, 8, 15, 16, 23, 42),
+           'kwargs': {
+               'secret_button_pushed': True,
+           },
+           'call_original': True,
+       }
+   ]))
+
+
 FAQ
 ===
 
diff --git a/kgb/__init__.py b/kgb/__init__.py
index fedcd663c99b55df3b03569865c414ef03b2f03d..23d7efa9ce1ca1e7a83072fa4e315f0f69ad1efa 100644
--- a/kgb/__init__.py
+++ b/kgb/__init__.py
@@ -2,6 +2,7 @@ from __future__ import unicode_literals
 
 from kgb.agency import SpyAgency
 from kgb.contextmanagers import spy_on
+from kgb.ops import SpyOpMatchAny, SpyOpMatchInOrder, SpyOpRaise, SpyOpReturn
 
 
 # The version of kgb
@@ -55,6 +56,10 @@ __all__ = [
     '__version__',
     '__version_info__',
     'SpyAgency',
+    'SpyOpMatchAny',
+    'SpyOpMatchInOrder',
+    'SpyOpRaise',
+    'SpyOpReturn',
     'VERSION',
     'get_package_version',
     'get_version_string',
diff --git a/kgb/errors.py b/kgb/errors.py
index bccc810b30843b3c044d9f230666291407c81b1f..a5c318419d7e3b78b4f13a9377b2ee238e5f2e07 100644
--- a/kgb/errors.py
+++ b/kgb/errors.py
@@ -80,3 +80,7 @@ class IncompatibleFunctionError(ValueError):
                incompatible_func_sig.format_arg_spec(),
                func,
                func_sig.format_arg_spec()))
+
+
+class UnexpectedCallError(AssertionError):
+    """A call was made to a spy that was not expected."""
diff --git a/kgb/ops.py b/kgb/ops.py
new file mode 100644
index 0000000000000000000000000000000000000000..d81a144ebf41e07df8b504c25607618538587261
--- /dev/null
+++ b/kgb/ops.py
@@ -0,0 +1,509 @@
+"""Planned operations for spies to perform."""
+
+from __future__ import unicode_literals
+
+from kgb.errors import UnexpectedCallError
+
+
+class BaseSpyOperation(object):
+    """Base class for a spy operation.
+
+    Spy operations can be performed when a spied-on function is called,
+    handling it according to a plan provided by the caller. They're registered
+    by passing ``op=`` when spying on the function.
+
+    There are a handful of built-in operations in KGB, but projects can
+    subclass this and define their own.
+    """
+
+    def handle_call(self, spy_call, *args, **kwargs):
+        """Handle a call to this operation.
+
+        Args:
+            spy_call (kgb.calls.SpyCall):
+                The call to handle.
+
+            *args (tuple):
+                Positional arguments passed into the call. This will be
+                normalized to not contain an object instance for bound
+                method or class methods.
+
+            **kwargs (tuple):
+                Keyword arguments passed into the call.
+
+        Returns:
+            object:
+            The value to return to the caller of the spied function.
+
+        Raises:
+            Exception:
+                Any exception to raise to the caller of the spied function.
+        """
+        raise NotImplementedError
+
+    def setup(self, spy):
+        """Set up the operation.
+
+        This associates the spy with the operation, and then returns a fake
+        function to set for the spy, which will in turn call the operation's
+        handler.
+
+        Args:
+            spy (kgb.spies.FunctionSpy):
+                The spy this operation is for.
+
+        Returns:
+            callable:
+            The fake function to set up with the spy.
+        """
+        self.spy = spy
+
+        if spy.func_type == spy.TYPE_BOUND_METHOD:
+            def fake_func(_self, *args, **kwargs):
+                return self._on_spy_call(*args, **kwargs)
+        else:
+            def fake_func(*args, **kwargs):
+                return self._on_spy_call(*args, **kwargs)
+
+        return fake_func
+
+    def _on_spy_call(self, *args, **kwargs):
+        """Internal handler for a call to this operation.
+
+        This normalizes and sanity-checks the arguments and then calls
+        :py:meth:`handle_call`.
+
+        Args:
+            *args (tuple):
+                All positional arguments made in the call. This may include the
+                object instance for bound methods or the class for
+                classmethods.
+
+            **kwargs (dict):
+                All keyword arguments made in the call.
+
+        Returns:
+            object:
+            The value to return to the caller of the spied function.
+
+        Raises:
+            Exception:
+                Any exception to raise to the caller of the spied function.
+        """
+        spy = self.spy
+        spy_call = spy.last_call
+
+        if spy.func_type == spy.TYPE_UNBOUND_METHOD:
+            assert spy_call.called_with(*args[1:], **kwargs)
+        else:
+            assert spy_call.called_with(*args, **kwargs)
+
+        return self.handle_call(spy_call, *args, **kwargs)
+
+
+class BaseMatchingSpyOperation(BaseSpyOperation):
+    """Base class for a operation that handles calls based on matched rules.
+
+    This helps subclasses to call consumer-defined handlers for calls based on
+    some kind of conditions. For instance, based on arguments, or the order in
+    which calls are made.
+    """
+
+    def __init__(self, calls):
+        """Initialize the operation.
+
+        By default, this takes a list of configurations for matching calls,
+        which the subclass will use to validate and handle a call.
+
+        The calls are a list of dictionaries with the following keys:
+
+        ``args`` (:py:class:`tuple`, optional):
+            Positional arguments for a match.
+
+        ``kwargs`` (:py:class:`dict`, optional):
+            Keyword arguments for a match.
+
+        ``call_fake`` (:py:class:`callable`, optional):
+            A function to call when all arguments have matched. This takes
+            precedence over ``call_original``.
+
+        ``call_original`` (:py:class:`bool`, optional):
+            Whether to call the original function. This is the default if
+            ``call_fake`` is not provided.
+
+        Subclasses may define custom keys.
+
+        Args:
+            calls (list of dict):
+                A list of call match configurations.
+        """
+        super(BaseMatchingSpyOperation, self).__init__()
+
+        self._calls = calls
+
+    def get_call_match_config(self, spy_call):
+        """Return a call match configuration for a call.
+
+        This will typically be one of the call match configurations provided
+        during initialization.
+
+        Subclasses must override this to return a dictionary containing
+        information that can be used to assert, track, and handle a call.
+        If they can't find a suitable call, they must raise
+        :py:class:`kgb.errors.UnexpectedCallError`.
+
+        Args:
+            spy_call (kgb.calls.SpyCall):
+                The call to return a match for.
+
+        Returns:
+            dict:
+            The call match configuration.
+
+        Raises:
+            kgb.errors.UnexpectedCallError:
+                A call match configuration could not be found. Details should
+                be in the error message.
+        """
+        raise NotImplementedError
+
+    def validate_call(self, call_match_config):
+        """Validate that the last call matches the call configuration.
+
+        This will assert that the last call matches the ``args`` and ``kwargs``
+        from the given call match configuration.
+
+        Subclasses can override this to check other conditions.
+
+        Args:
+            call_match_config (dict):
+                The call match configuration returned from
+                :py:meth:`get_call_match_config` for the last call.
+
+        Raises:
+            AssertionError:
+                The call did not match the configuration.
+        """
+        self.spy.agency.assertSpyCalledWith(
+            self.spy.last_call,
+            *call_match_config.get('args', ()),
+            **call_match_config.get('kwargs', {}))
+
+    def handle_call(self, spy_call, *args, **kwargs):
+        """Handle a call to this operation.
+
+        This will find a suitable call match configuration, if one was
+        provided, and then call either the fake function (if ``call_fake`` was
+        provided), the original function (if ``call_original`` is not set to
+        ``False``), or return ``None``.
+
+        Args:
+            spy_call (kgb.calls.SpyCall):
+                The call to handle.
+
+            *args (tuple):
+                Positional arguments passed into the call. This will be
+                normalized to not contain an object instance for bound
+                method or class methods.
+
+            **kwargs (tuple):
+                Keyword arguments passed into the call.
+
+        Returns:
+            object:
+            The value to return to the caller of the spied function.
+            This may be returned by a fake or original function.
+
+        Raises:
+            AssertionError:
+                The call did not match the returned configuration.
+
+            Exception:
+                Any exception to raise to the caller of the spied function.
+                This may be raised by a fake or original function.
+
+            kgb.errors.UnexpectedCallError:
+                A call match configuration could not be found. Details should
+                be in the error message.
+        """
+        call_match_config = self.get_call_match_config(spy_call)
+        assert call_match_config is not None
+
+        self.validate_call(call_match_config)
+
+        # We'll be respecting these arguments in the order that FunctionSpy
+        # would with its parameters.
+        func = call_match_config.get('call_fake')
+
+        if func is not None:
+            return func(*args, **kwargs)
+
+        if call_match_config.get('call_original', True):
+            return self.spy.call_original(*args, **kwargs)
+
+        return None
+
+
+class SpyOpMatchAny(BaseMatchingSpyOperation):
+    """A operation for handling one or more expected calls in any order.
+
+    This is used to list the calls (specifying positional and keyword
+    arguments) that are expected to be made, raising an error if any calls are
+    made that weren't expected.
+
+    Each of those expected sets of arguments can optionally result in a call to
+    a fake function or the original function. This can be specified per set of
+    arguments.
+
+    Example:
+        spy_on(traps.trigger, op=SpyOpMatchAny([
+            {
+                'args': ('hallway_lasers',),
+                'call_fake': _send_wolves,
+            },
+            {
+                'args': ('trap_tile',),
+                'call_fake': _spill_hot_oil,
+            },
+            {
+                'args': ('infrared_camera',),
+                'kwargs': {
+                    'sector': 'underground_passage',
+                },
+                'call_original': False,
+            },
+        ]))
+    """
+
+    def __init__(self, calls):
+        """Initialize the operation.
+
+        This takes a list of configurations for matching calls, which can be
+        called in any order.
+        those calls are expected.
+
+        The calls are a list of dictionaries with the following keys:
+
+        ``args`` (:py:class:`tuple`, optional):
+            Positional arguments for a match.
+
+        ``kwargs`` (:py:class:`dict`, optional):
+            Keyword arguments for a match.
+
+        ``call_fake`` (:py:class:`callable`, optional):
+            A function to call when all arguments have matched. This takes
+            precedence over ``call_original``.
+
+        ``call_original`` (:py:class:`bool`, optional):
+            Whether to call the original function. This is the default if
+            ``call_fake`` is not provided.
+
+        Args:
+            calls (list of dict):
+                A list of call match configurations.
+        """
+        super(SpyOpMatchAny, self).__init__(calls)
+
+    def get_call_match_config(self, spy_call):
+        """Return a call match configuration for a call.
+
+        This will check if there are any call match configurations provided
+        during initialization that match the call.
+
+        Args:
+            spy_call (kgb.calls.SpyCall):
+                The call to return a match for.
+
+        Returns:
+            dict:
+            The call match configuration.
+
+        Raises:
+            kgb.errors.UnexpectedCallError:
+                A call match configuration could not be found. Details should
+                be in the error message.
+        """
+        for call_match_config in self._calls:
+            if spy_call.called_with(*call_match_config.get('args', ()),
+                                    **call_match_config.get('kwargs', {})):
+                return call_match_config
+
+        raise UnexpectedCallError(
+            '%(spy)s was not called with any expected arguments.'
+            % {
+                'spy': self.spy.func_name,
+            })
+
+
+class SpyOpMatchInOrder(BaseMatchingSpyOperation):
+    """A operation for handling expected calls in a given order.
+
+    This is used to list the calls (specifying positional and keyword
+    arguments) that are expected to be made, in the order they should be made,
+    raising an error if too many calls were made or a call didn't match the
+    expected arguments.
+
+    Each of those expected sets of arguments can optionally result in a call to
+    a fake function or the original function. This can be specified per set of
+    arguments.
+
+    Example:
+        spy_on(lockbox.enter_code, op=SpyOpMatchInOrder([
+            {
+                'args': (1, 2, 3, 4, 5, 6),
+                'call_original': False,
+            },
+            {
+                'args': (9, 0, 2, 1, 0, 0),
+                'call_fake': _start_countdown,
+            },
+            {
+                'args': (4, 8, 15, 16, 23, 42),
+                'kwargs': {
+                    'secret_button_pushed': True,
+                },
+                'call_original': True,
+            }
+        ]))
+    """
+
+    def __init__(self, calls):
+        """Initialize the operation.
+
+        This takes a list of configurations for matching calls, in the order
+        those calls are expected.
+
+        The calls are a list of dictionaries with the following keys:
+
+        ``args`` (:py:class:`tuple`, optional):
+            Positional arguments for a match.
+
+        ``kwargs`` (:py:class:`dict`, optional):
+            Keyword arguments for a match.
+
+        ``call_fake`` (:py:class:`callable`, optional):
+            A function to call when all arguments have matched. This takes
+            precedence over ``call_original``.
+
+        ``call_original`` (:py:class:`bool`, optional):
+            Whether to call the original function. This is the default if
+            ``call_fake`` is not provided.
+
+        Args:
+            calls (list of dict):
+                A list of call match configurations.
+        """
+        super(SpyOpMatchInOrder, self).__init__(calls)
+
+        self._next = 0
+
+    def get_call_match_config(self, spy_call):
+        """Return a call match configuration for a call.
+
+        This will check if the spy call matches the next call match
+        configuration in the list provided by the consumer.
+
+        Args:
+            spy_call (kgb.calls.SpyCall):
+                The call to return a match for.
+
+        Returns:
+            dict:
+            The call match configuration.
+
+        Raises:
+            kgb.errors.UnexpectedCallError:
+                Too many calls were made to the function.
+        """
+        i = self._next
+
+        try:
+            call_match_config = self._calls[i]
+        except IndexError:
+            raise UnexpectedCallError(
+                '%(spy)s was called %(num_calls)s time(s), but only '
+                '%(expected_calls)s call(s) were expected.'
+                % {
+                    'expected_calls': len(self._calls),
+                    'num_calls': i + 1,
+                    'spy': self.spy.func_name,
+                })
+
+        self._next += 1
+
+        return call_match_config
+
+
+class SpyOpRaise(BaseSpyOperation):
+    """An operation for raising an exception.
+
+    This is used to simulate a failure of some sort in a function or method.
+
+    Example:
+        spy_on(pen.emit_poison, op=SpyOpRaise(PoisonEmptyError()))
+    """
+
+    def __init__(self, exc):
+        """Initialize the operation.
+
+        Args:
+            exc (Exception):
+                The exception instance to raise when the function is called.
+        """
+        self.exc = exc
+
+    def handle_call(self, *args, **kwargs):
+        """Handle a call to this operation.
+
+        This will raise the exception provided to the operation.
+
+        Args:
+            *args (tuple, ignored):
+                Positional arguments passed into the call.
+
+            **kwargs (tuple, ignored):
+                Keyword arguments passed into the call.
+
+        Raises:
+            Exception:
+                The exception provided to the operation.
+        """
+        raise self.exc
+
+
+class SpyOpReturn(BaseSpyOperation):
+    """An operation for returning a value.
+
+    This is used to simulate a simple result from a function call without
+    having to override the method or provide a lambda.
+
+    Example:
+        spy_on(our_agent.get_identity, op=SpyOpReturn('nobody...'))
+    """
+
+    def __init__(self, return_value):
+        """Initialize the operation.
+
+        Args:
+            return_value (object):
+                The value to return when the function is called.
+        """
+        self.return_value = return_value
+
+    def handle_call(self, *args, **kwargs):
+        """Handle a call to this operation.
+
+        This will return the value provided to the operation.
+
+        Args:
+            *args (tuple, ignored):
+                Positional arguments passed into the call.
+
+            **kwargs (tuple, ignored):
+                Keyword arguments passed into the call.
+
+        Returns:
+            object:
+            The return value provided to the operation.
+        """
+        return self.return_value
diff --git a/kgb/spies.py b/kgb/spies.py
index 6a7d447b90279e41b3ba179966dde0bddef619d6..6695fc76ec87aa06ceccb3f33bc0c919cd679ab2 100644
--- a/kgb/spies.py
+++ b/kgb/spies.py
@@ -58,7 +58,7 @@ class FunctionSpy(object):
     _spy_map = {}
 
     def __init__(self, agency, func, call_fake=None, call_original=True,
-                 owner=_UNSET_ARG):
+                 op=None, owner=_UNSET_ARG):
         """Initialize the spy.
 
         This will begin spying on the provided function or method, injecting
@@ -81,11 +81,18 @@ class FunctionSpy(object):
             call_fake (callable, optional):
                 The optional function to call when this function is invoked.
 
+                This cannot be specified if ``op`` is provided.
+
             call_original (bool, optional):
                 Whether to call the original function when the spy is
                 invoked. If ``False``, no function will be called.
 
-                This is ignored if ``call_fake`` is provided.
+                This is ignored if ``call_fake`` or ``op`` are provided.
+
+            op (kgb.spies.BaseOperation, optional):
+                An operation to perform.
+
+                This cannot be specified if ``call_fake`` is provided.
 
             owner (type or object, optional):
                 The owner of the function or method.
@@ -104,6 +111,9 @@ class FunctionSpy(object):
 
         # Check the parameters passed to make sure that invalid data wasn't
         # provided.
+        if op is not None and call_fake is not None:
+            raise ValueError('op and call_fake cannot both be provided.')
+
         if hasattr(func, 'spy'):
             raise ExistingSpyError(func)
 
@@ -152,6 +162,13 @@ class FunctionSpy(object):
 
         # If call_fake was provided, check that it's valid and has a
         # compatible function signature.
+        if op is not None:
+            # We've already checked this above, but check it again.
+            assert call_fake is None
+
+            call_fake = op.setup(self)
+            assert call_fake is not None
+
         if call_fake is not None:
             if not callable(call_fake):
                 raise ValueError('%r cannot be used for call_fake. It does '
diff --git a/kgb/tests/test_ops.py b/kgb/tests/test_ops.py
new file mode 100644
index 0000000000000000000000000000000000000000..9bf487fa769f5a33d552e0c2d5e95a9013f8b8b2
--- /dev/null
+++ b/kgb/tests/test_ops.py
@@ -0,0 +1,384 @@
+"""Unit tests for kgb.ops."""
+
+from __future__ import unicode_literals
+
+import re
+
+from kgb.errors import UnexpectedCallError
+from kgb.ops import SpyOpMatchAny, SpyOpMatchInOrder, SpyOpRaise, SpyOpReturn
+from kgb.tests.base import MathClass, TestCase
+
+
+class SpyOpMatchAnyTests(TestCase):
+    """Unit tests for kgb.ops.SpyOpMatchAny."""
+
+    def test_setup_with_instance(self):
+        """Testing SpyOpMatchAny set up with op=SpyOpMatchAny([...])"""
+        obj = MathClass()
+
+        self.agency.spy_on(
+            obj.do_math,
+            op=SpyOpMatchAny([
+                {
+                    'kwargs': {
+                        'a': 1,
+                        'b': 2,
+                    },
+                    'call_fake': lambda a, b: a - b,
+                },
+            ]))
+
+        self.assertEqual(obj.do_math(a=1, b=2), -1)
+
+    def test_with_function(self):
+        """Testing SpyOpMatchAny with function"""
+        def do_math(a, b):
+            return a + b
+
+        self.agency.spy_on(
+            do_math,
+            op=SpyOpMatchAny([
+                {
+                    'args': [5, 3],
+                    'call_fake': lambda a, b: a - b
+                },
+            ]))
+
+        self.assertEqual(do_math(5, 3), 2)
+
+    def test_with_classmethod(self):
+        """Testing SpyOpMatchAny with classmethod"""
+        self.agency.spy_on(
+            MathClass.class_do_math,
+            owner=MathClass,
+            op=SpyOpMatchAny([
+                {
+                    'kwargs': {
+                        'a': 5,
+                        'b': 3,
+                    },
+                    'call_fake': lambda a, b: a - b
+                },
+            ]))
+
+        self.assertEqual(MathClass.class_do_math(a=5, b=3), 2)
+
+    def test_with_unbound_method(self):
+        """Testing SpyOpMatchAny with unbound method"""
+        self.agency.spy_on(
+            MathClass.do_math,
+            owner=MathClass,
+            op=SpyOpMatchAny([
+                {
+                    'kwargs': {
+                        'a': 4,
+                        'b': 3,
+                    },
+                },
+            ]))
+
+        obj = MathClass()
+
+        self.assertEqual(obj.do_math(a=4, b=3), 7)
+
+    def test_with_expected_calls(self):
+        """Testing SpyOpMatchAny with all expected calls"""
+        obj = MathClass()
+
+        self.agency.spy_on(
+            obj.do_math,
+            op=SpyOpMatchAny([
+                {
+                    'kwargs': {
+                        'a': 4,
+                        'b': 7,
+                    },
+                },
+                {
+                    'kwargs': {
+                        'a': 2,
+                        'b': 8,
+                    },
+                    'call_original': False,
+                },
+                {
+                    'kwargs': {
+                        'a': 5,
+                        'b': 9,
+                    },
+                    'call_fake': lambda a, b: a + b + 10,
+                },
+                {
+                    'a': 2,
+                    'call_fake': lambda a, b: 1001,
+                },
+            ]))
+
+        values = [
+            obj.do_math(5, b=9),
+            obj.do_math(a=2, b=8),
+            obj.do_math(a=1, b=1),
+            obj.do_math(4, 7),
+        ]
+
+        self.assertEqual(values, [24, None, 1001, 11])
+
+    def test_with_unexpected_call(self):
+        """Testing SpyOpMatchAny with unexpected call"""
+        obj = MathClass()
+
+        self.agency.spy_on(
+            obj.do_math,
+            op=SpyOpMatchAny([
+                {
+                    'kwargs': {
+                        'a': 4,
+                        'b': 7,
+                    },
+                },
+            ]))
+
+        expected_message = re.escape(
+            'do_math was not called with any expected arguments.'
+        )
+
+        with self.assertRaisesRegexp(AssertionError, expected_message):
+            obj.do_math(a=4, b=9)
+
+
+class SpyOpMatchInOrderTests(TestCase):
+    """Unit tests for kgb.ops.SpyOpMatchInOrder."""
+
+    def test_setup_with_instance(self):
+        """Testing SpyOpMatchInOrder set up with op=SpyOpMatchInOrder([...])"""
+        obj = MathClass()
+
+        self.agency.spy_on(
+            obj.do_math,
+            op=SpyOpMatchInOrder([
+                {
+                    'kwargs': {
+                        'a': 1,
+                        'b': 2,
+                    },
+                },
+            ]))
+
+        self.assertEqual(obj.do_math(a=1, b=2), 3)
+
+    def test_with_function(self):
+        """Testing SpyOpMatchInOrder with function"""
+        def do_math(a, b):
+            return a + b
+
+        self.agency.spy_on(
+            do_math,
+            op=SpyOpMatchInOrder([
+                {
+                    'args': [5, 3],
+                    'call_fake': lambda a, b: a - b
+                },
+            ]))
+
+        self.assertEqual(do_math(5, 3), 2)
+
+    def test_with_classmethod(self):
+        """Testing SpyOpMatchInOrder with classmethod"""
+        self.agency.spy_on(
+            MathClass.class_do_math,
+            owner=MathClass,
+            op=SpyOpMatchInOrder([
+                {
+                    'kwargs': {
+                        'a': 5,
+                        'b': 3,
+                    },
+                    'call_fake': lambda a, b: a - b
+                },
+            ]))
+
+        self.assertEqual(MathClass.class_do_math(a=5, b=3), 2)
+
+    def test_with_unbound_method(self):
+        """Testing SpyOpMatchInOrder with unbound method"""
+        self.agency.spy_on(
+            MathClass.do_math,
+            owner=MathClass,
+            op=SpyOpMatchInOrder([
+                {
+                    'kwargs': {
+                        'a': 4,
+                        'b': 3,
+                    },
+                },
+            ]))
+
+        obj = MathClass()
+
+        self.assertEqual(obj.do_math(a=4, b=3), 7)
+
+    def test_with_expected_calls(self):
+        """Testing SpyOpMatchInOrder with all expected calls"""
+        obj = MathClass()
+
+        self.agency.spy_on(
+            obj.do_math,
+            op=SpyOpMatchInOrder([
+                {
+                    'kwargs': {
+                        'a': 4,
+                        'b': 7,
+                    },
+                },
+                {
+                    'kwargs': {
+                        'a': 2,
+                        'b': 8,
+                    },
+                    'call_original': False,
+                },
+                {
+                    'kwargs': {
+                        'a': 5,
+                        'b': 9,
+                    },
+                    'call_fake': lambda a, b: a + b + 10,
+                },
+                {
+                    'call_fake': lambda a, b: 1001,
+                },
+            ]))
+
+        values = [
+            obj.do_math(4, 7),
+            obj.do_math(a=2, b=8),
+            obj.do_math(5, b=9),
+            obj.do_math(a=1, b=1),
+        ]
+
+        self.assertEqual(values, [11, None, 24, 1001])
+
+    def test_with_unexpected_call(self):
+        """Testing SpyOpMatchInOrder with unexpected call"""
+        obj = MathClass()
+
+        self.agency.spy_on(
+            obj.do_math,
+            op=SpyOpMatchInOrder([
+                {
+                    'kwargs': {
+                        'a': 4,
+                        'b': 7,
+                    },
+                },
+            ]))
+
+        expected_message = re.escape(
+            "This call to do_math was not passed args=(), "
+            "kwargs={'a': 4, 'b': 7}.\n"
+            "\n"
+            "It was called with:\n"
+            "\n"
+            "args=()\n"
+            "kwargs={'a': 4, 'b': 9}"
+        )
+
+        with self.assertRaisesRegexp(AssertionError, expected_message):
+            obj.do_math(a=4, b=9)
+
+    def test_with_extra_call(self):
+        """Testing SpyOpMatchInOrder with extra unexpected call"""
+        obj = MathClass()
+
+        self.agency.spy_on(
+            obj.do_math,
+            op=SpyOpMatchInOrder([
+                {
+                    'kwargs': {
+                        'a': 4,
+                        'b': 7,
+                    },
+                },
+            ]))
+
+        self.assertEqual(obj.do_math(a=4, b=7), 11)
+
+        expected_message = re.escape(
+            'do_math was called 2 time(s), but only 1 call(s) were expected.'
+        )
+
+        with self.assertRaisesRegexp(UnexpectedCallError, expected_message):
+            obj.do_math(a=4, b=9)
+
+
+class SpyOpRaiseTests(TestCase):
+    """Unit tests for kgb.ops.SpyOpRaise."""
+
+    def test_with_function(self):
+        """Testing SpyOpRaise with function"""
+        def do_math(a, b):
+            return a + b
+
+        self.agency.spy_on(
+            do_math,
+            op=SpyOpRaise(ValueError('foo')))
+
+        with self.assertRaisesRegexp(ValueError, 'foo'):
+            do_math(5, 3)
+
+    def test_with_classmethod(self):
+        """Testing SpyOpRaise with classmethod"""
+        self.agency.spy_on(
+            MathClass.class_do_math,
+            owner=MathClass,
+            op=SpyOpRaise(ValueError('foo')))
+
+        with self.assertRaisesRegexp(ValueError, 'foo'):
+            MathClass.class_do_math(5, 3)
+
+    def test_with_unbound_method(self):
+        """Testing SpyOpRaise with unbound method"""
+        self.agency.spy_on(
+            MathClass.do_math,
+            owner=MathClass,
+            op=SpyOpRaise(ValueError('foo')))
+
+        obj = MathClass()
+
+        with self.assertRaisesRegexp(ValueError, 'foo'):
+            obj.do_math(a=4, b=3)
+
+
+class SpyOpReturnTests(TestCase):
+    """Unit tests for kgb.ops.SpyOpReturn."""
+
+    def test_with_function(self):
+        """Testing SpyOpReturn with function"""
+        def do_math(a, b):
+            return a + b
+
+        self.agency.spy_on(
+            do_math,
+            op=SpyOpReturn('abc123'))
+
+        self.assertEqual(do_math(5, 3), 'abc123')
+
+    def test_with_classmethod(self):
+        """Testing SpyOpReturn with classmethod"""
+        self.agency.spy_on(
+            MathClass.class_do_math,
+            owner=MathClass,
+            op=SpyOpReturn('abc123'))
+
+        self.assertEqual(MathClass.class_do_math(5, 3), 'abc123')
+
+    def test_with_unbound_method(self):
+        """Testing SpyOpReturn with unbound method"""
+        self.agency.spy_on(
+            MathClass.do_math,
+            owner=MathClass,
+            op=SpyOpReturn('abc123'))
+
+        obj = MathClass()
+
+        self.assertEqual(obj.do_math(a=4, b=3), 'abc123')
