diff --git a/tests/test_parser_combinator.py b/tests/test_parser_combinator.py
new file mode 100644
index 0000000000000000000000000000000000000000..c718b45f02fffa7b54ad7b2aa6c5d3502455fce7
--- /dev/null
+++ b/tests/test_parser_combinator.py
@@ -0,0 +1,191 @@
+from muppet.parser_combinator import (
+    MatchLiteral,
+    MatchObject,
+    ParseDirective,
+    ParseError,
+    ParserCombinator,
+    all_,
+    char,
+    complement,
+    count,
+    digit,
+    hexdig,
+    line_comment,
+    many,
+    many1,
+    name,
+    nop,
+    not_,
+    optional,
+    s,
+    tag,
+    ws,
+    delimited,
+    discard,
+    space,
+)
+
+
+def lit(x):
+    return MatchLiteral(matched=x)
+
+
+def test_partial():
+    data = "123 Hello"
+
+    parser = ParserCombinator(data)
+
+    assert [lit("123")] == parser.get(many(digit))
+    assert " Hello" == parser.remaining()
+
+
+def test_char():
+    parser = ParserCombinator("Hello!")
+    assert [lit('H')] == parser.get(char)
+    assert [lit('e')] == parser.get(char)
+    assert "llo!" == parser.remaining()
+
+
+def test_nop():
+    parser = ParserCombinator("Hello!")
+    assert [] == parser.get(nop)
+    assert "Hello!" == parser.remaining()
+
+
+def test_digit():
+    p1 = ParserCombinator("123")
+    assert [lit('1')] == p1.get(digit)
+    p2 = ParserCombinator("Hello")
+    try:
+        p2.get(digit)
+        assert False, "Parser should have failed, but didn't"
+    except ParseError:
+        assert "Hello" == p2.remaining()
+
+
+def test_consume():
+    p = ParserCombinator("Hello")
+    try:
+        p.get([char, digit])
+        assert False, "Parser should have failed, but didn't"
+    except ParseError:
+        assert "ello" == p.remaining()
+
+
+def test_hexdig():
+    pass
+
+
+def test_space():
+    pass
+
+# --------------------------------------------------
+
+
+def test_many():
+    p1 = ParserCombinator("Hello, World!")
+
+    assert [lit("Hello, World!")] == p1.get(many(char))
+
+    p2 = ParserCombinator("")
+    assert [] == p2.get(many(char))
+
+
+def test_many1():
+    p1 = ParserCombinator("Hello, World!")
+
+    assert [lit("Hello, World!")] == p1.get(many1(char))
+
+    p2 = ParserCombinator("")
+    try:
+        p2.get(many1(char))
+        assert False, "Parser should have failed, but didn't"
+    except ParseError:
+        assert True
+
+
+def test_count():
+    p1 = ParserCombinator("ABCDE")
+    assert [lit("A")] == p1.get(count(s("A"), 1, 3))
+
+    p2 = ParserCombinator("AAAAA")
+    assert [lit("AAA")] == p2.get(count(s("A"), 1, 3))
+
+    p3 = ParserCombinator("BBBBB")
+    assert [] == p3.get(count(s("A"), 3))
+
+    p4 = ParserCombinator("AAAAA")
+    assert [lit("AAA")] == p4.get(count(s("A"), 3))
+
+
+def test_optional():
+    p1 = ParserCombinator("ABC")
+    assert [lit("A")] == p1.get(optional(s("A")))
+    assert "BC" == p1.remaining()
+
+    p2 = ParserCombinator("ABC")
+    assert [] == p2.get(optional(s("B")))
+    assert "ABC" == p2.remaining()
+
+
+def test_ws():
+    p1 = ParserCombinator("Hello")
+    assert [] == p1.get(ws)
+
+    p2 = ParserCombinator("\t \n\r Hello")
+    assert [lit("\t \n\r ")] == p2.get(ws)
+
+
+def test_discard():
+    p1 = ParserCombinator("Hello")
+    assert [] == p1.get(discard(s("He")))
+    assert "llo" == p1.remaining()
+
+    p2 = ParserCombinator("Hello!")
+    assert [lit("ll")] == p2.get(discard(s("He")) & s("ll") & discard(s("o")))
+    assert "!" == p2.remaining()
+
+
+def handle_int(xs: list[MatchObject]) -> list[int]:
+    """Convert matched to an integer."""
+    return [int(xs[0].matched)]
+
+
+def test_delimited():
+    number = many(digit) @ handle_int
+
+    p1 = ParserCombinator("1,20,2")
+    assert [1, lit(","), 20, lit(","), 2] == p1.get(delimited(s(","), number))
+
+    p2 = ParserCombinator("1,20,2")
+    assert [1, 20, 2] == p2.get(delimited(discard(s(",")), number))
+
+
+def test_all():
+    p1 = ParserCombinator("123")
+    assert [lit("1")] == p1.get(all_(char, digit))
+
+
+def test_complement_1():
+    p1 = ParserCombinator("Hello, World!")
+    assert [lit("H")] == p1.get(all_(~ space, char))
+
+
+def test_complement_2():
+    p1 = ParserCombinator("Hello, World!")
+    assert [lit("Hello,")] == p1.get(many(all_(~ space, char)))
+
+
+def test_complement():
+    p = ParserCombinator("Hello")
+    assert [lit("He")] == p.get(many(complement("l")))
+
+
+def test_stringifiers():
+    assert "'a'" == str(s("a"))
+    assert "~ 'a'" == repr(~ s("a"))
+    assert "x" == str(name("x", space & space))
+    assert "('a' & 'b')" == str(s('a') & s('b'))
+    assert "('a' | 'b')" == str(s('a') | s('b'))
+    assert "char" == str(char)
+    assert "nop" == str(nop)
diff --git a/tests/test_util.py b/tests/test_util.py
new file mode 100644
index 0000000000000000000000000000000000000000..f382835fa1218f7d305547be21db1c199a1b34f6
--- /dev/null
+++ b/tests/test_util.py
@@ -0,0 +1,14 @@
+from muppet.util import group_by, concatenate
+
+
+def test_group_by():
+    groups = group_by(lambda x: x % 3, range(10))
+    assert len(groups) == 3
+    assert groups[0] == [0, 3, 6, 9]
+    assert groups[1] == [1, 4, 7]
+    assert groups[2] == [2, 5, 8]
+
+
+def test_concatenate():
+    assert concatenate([[1, 2], [3, 4]]) == [1, 2, 3, 4]
+    assert concatenate([[1, [2]], [3, 4]]) == [1, [2], 3, 4]