diff --git a/src/_zkapauthorizer/spending.py b/src/_zkapauthorizer/spending.py
index 2e44de96f4af2157d9db61f1b425c42e9d733f8d..a2836e18f90eec1e13ff8c32edf311e25b8dad82 100644
--- a/src/_zkapauthorizer/spending.py
+++ b/src/_zkapauthorizer/spending.py
@@ -145,6 +145,7 @@ class PassGroup(object):
         self._factory._reset(self.passes)
 
 
+@implementer(IPassFactory)
 @attr.s
 class SpendingController(object):
     """
diff --git a/src/_zkapauthorizer/tests/matchers.py b/src/_zkapauthorizer/tests/matchers.py
index 79b4febf24c6cad07c30b10f837aa3e9a74b92f4..5ea2613373b6b2b10bb91113031f45ad8fbfd42c 100644
--- a/src/_zkapauthorizer/tests/matchers.py
+++ b/src/_zkapauthorizer/tests/matchers.py
@@ -54,7 +54,7 @@ class Provides(object):
     """
     Match objects that provide all of a list of Zope Interface interfaces.
     """
-    interfaces = attr.ib()
+    interfaces = attr.ib(validator=attr.validators.instance_of(list))
 
     def match(self, obj):
         missing = set()
diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py
index 28028fd87ad78348725343f9ac19bf710c6eb040..0c448cda3cce269ab18715c4de2fa560837b80d8 100644
--- a/src/_zkapauthorizer/tests/strategies.py
+++ b/src/_zkapauthorizer/tests/strategies.py
@@ -813,3 +813,12 @@ def node_hierarchies():
     ).filter(
         storage_indexes_are_distinct,
     )
+
+
+def pass_counts():
+    """
+    Build integers usable as a number of passes to work on.  There is always
+    at least one pass in a group and there are never "too many", whatever that
+    means.
+    """
+    return integers(min_value=1, max_value=2 ** 8)
diff --git a/src/_zkapauthorizer/tests/test_spending.py b/src/_zkapauthorizer/tests/test_spending.py
new file mode 100644
index 0000000000000000000000000000000000000000..62473bb8f288117a16189598b22fd457b611ccfb
--- /dev/null
+++ b/src/_zkapauthorizer/tests/test_spending.py
@@ -0,0 +1,112 @@
+# Copyright 2019 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Tests for ``_zkapauthorizer.spending``.
+"""
+
+from testtools import (
+    TestCase,
+)
+from testtools.matchers import (
+    Always,
+    MatchesAll,
+    MatchesStructure,
+    HasLength,
+)
+from testtools.twistedsupport import (
+    succeeded,
+)
+
+from hypothesis import (
+    given,
+)
+
+from twisted.python.filepath import (
+    FilePath,
+)
+
+from .strategies import (
+    vouchers,
+    pass_counts,
+    posix_safe_datetimes,
+)
+from .matchers import (
+    Provides,
+)
+from ..model import (
+    VoucherStore,
+    open_and_initialize,
+    memory_connect,
+)
+from ..controller import (
+    DummyRedeemer,
+    PaymentController,
+)
+from ..spending import (
+    IPassGroup,
+    SpendingController,
+)
+
+class PassGroupTests(TestCase):
+    """
+    Tests for ``IPassGroup`` and the factories that create them.
+    """
+    @given(vouchers(), pass_counts(), posix_safe_datetimes())
+    def test_get(self, voucher, num_passes, now):
+        """
+        ``IPassFactory.get`` returns an ``IPassGroup`` provider containing the
+        requested number of passes.
+        """
+        redeemer = DummyRedeemer()
+        here = FilePath(u".")
+        store = VoucherStore(
+            pass_value=2 ** 15,
+            database_path=here,
+            now=lambda: now,
+            connection=open_and_initialize(here, memory_connect),
+        )
+        # Make sure there are enough tokens for us to extract!
+        self.assertThat(
+            PaymentController(
+                store,
+                redeemer,
+                # Have to pass it here or to redeem, doesn't matter which.
+                default_token_count=num_passes,
+                # No value in splitting it into smaller groups in this case.
+                # Doing so only complicates the test by imposing a different
+                # minimum token count requirement (can't have fewer tokens
+                # than groups).
+                num_redemption_groups=1,
+            ).redeem(
+                voucher,
+            ),
+            succeeded(Always()),
+        )
+
+        pass_factory = SpendingController(
+            extract_unblinded_tokens=store.extract_unblinded_tokens,
+            tokens_to_passes=redeemer.tokens_to_passes,
+        )
+
+        group = pass_factory.get(u"message", num_passes)
+        self.assertThat(
+            group,
+            MatchesAll(
+                Provides([IPassGroup]),
+                MatchesStructure(
+                    passes=HasLength(num_passes),
+                ),
+            ),
+        )
diff --git a/src/_zkapauthorizer/tests/test_storage_client.py b/src/_zkapauthorizer/tests/test_storage_client.py
index ee5d1bc001923beb5957c0a3205e2b7bb8b650bb..5fd6b6d4c343788e05e584ab7fe7078080b73122 100644
--- a/src/_zkapauthorizer/tests/test_storage_client.py
+++ b/src/_zkapauthorizer/tests/test_storage_client.py
@@ -41,9 +41,6 @@ from testtools.twistedsupport import (
 from hypothesis import (
     given,
 )
-from hypothesis.strategies import (
-    integers,
-)
 
 from twisted.internet.defer import (
     succeed,
@@ -55,6 +52,10 @@ from .matchers import (
     odd,
 )
 
+from .strategies import (
+    pass_counts,
+)
+
 from ..api import (
     MorePassesRequired,
 )
@@ -71,10 +72,6 @@ from .storage_common import (
 )
 
 
-def pass_counts():
-    return integers(min_value=1, max_value=2 ** 8)
-
-
 class CallWithPassesTests(TestCase):
     """
     Tests for ``call_with_passes``.