diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 000000000..af820a45d
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,35 @@
+name: test
+
+on:
+  push:
+  pull_request:
+    types: [ opened, edited ]
+
+jobs:
+  pytest:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: [ '3.8', '3.x' ]
+
+    name: pytest ${{ matrix.python-version }}
+    steps:
+      - uses: actions/checkout@v2
+        with:
+          fetch-depth: 0
+
+      - name: Set up CPython ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip setuptools wheel "coverage[toml]" pytest pytest-asyncio pytest-cov pytest-mock
+          pip install -U -r requirements.txt
+
+      - name: Run tests
+        shell: bash
+        run: |
+          PYTHONPATH="$(pwd)" pytest -vs --cov=discord --cov-report term-missing:skip-covered
diff --git a/.gitignore b/.gitignore
index b556ebbb9..51ed18d76 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@ docs/crowdin.py
 *.jpg
 *.flac
 *.mo
+/.coverage
diff --git a/pyproject.toml b/pyproject.toml
index 736832917..db2927f26 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,6 +6,19 @@ build-backend = "setuptools.build_meta"
 line-length = 125
 skip-string-normalization = true
 
+[tool.coverage.run]
+omit = [
+    "discord/__main__.py",
+    "discord/types/*",
+    "*/_types.py",
+]
+
+[tool.coverage.report]
+exclude_lines = [
+    "pragma: no cover",
+    "@overload"
+]
+
 [tool.isort]
 profile = "black"
 combine_as_imports = true
@@ -29,3 +42,6 @@ exclude = [
 ]
 pythonVersion = "3.8"
 typeCheckingMode = "basic"
+
+[tool.pytest.ini_options]
+asyncio_mode = "strict"
diff --git a/setup.py b/setup.py
index af1ea214a..fcbae9591 100644
--- a/setup.py
+++ b/setup.py
@@ -43,6 +43,13 @@ extras_require = {
     ],
     'speed': [
         'orjson>=3.5.4',
+    ],
+    'test': [
+        'coverage[toml]',
+        'pytest',
+        'pytest-asyncio',
+        'pytest-cov',
+        'pytest-mock'
     ]
 }
 
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 000000000..b0150e655
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,261 @@
+# -*- coding: utf-8 -*-
+
+"""
+
+Tests for discord.utils
+
+"""
+
+import datetime
+import random
+import collections
+import secrets
+import sys
+import time
+import typing
+
+import pytest
+
+from discord import utils
+
+
+# Async generator for async support
+async def async_iterate(array):
+    for item in array:
+        yield item
+
+
+def test_cached_properties():
+    # cached_property
+    class Test:
+        @utils.cached_property
+        def time(self) -> float:
+            return time.perf_counter()
+
+    instance = Test()
+
+    assert instance.time == instance.time
+
+    # cached_slot_property
+    class TestSlotted:
+        __slots__ = (
+            '_cs_time'
+        )
+
+        @utils.cached_slot_property('_cs_time')
+        def time(self) -> float:
+            return time.perf_counter()
+
+    instance = TestSlotted()
+
+    assert instance.time == instance.time
+    assert not hasattr(instance, '__dict__')
+
+
+@pytest.mark.parametrize(
+    ('snowflake', 'time_tuple'),
+    [
+        (10000000000000000, (2015, 1, 28, 14, 16, 25)),
+        (12345678901234567, (2015, 2, 4, 1, 37, 19)),
+        (100000000000000000, (2015, 10, 3, 22, 44, 17)),
+        (123456789012345678, (2015, 12, 7, 16, 13, 12)),
+        (661720302316814366, (2020, 1, 1, 0, 0, 14)),
+        (1000000000000000000, (2022, 7, 22, 11, 22, 59)),
+    ]
+)
+def test_snowflake_time(snowflake: int, time_tuple: typing.Tuple[int, int, int, int, int, int]):
+    dt = utils.snowflake_time(snowflake)
+
+    assert (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) == time_tuple
+
+    assert utils.time_snowflake(dt, high=False) <= snowflake <= utils.time_snowflake(dt, high=True)
+
+
+@pytest.mark.asyncio
+async def test_get_find():
+    # Generate a dictionary of random keys to values
+    mapping = {
+        secrets.token_bytes(32): secrets.token_bytes(32)
+        for _ in range(100)
+    }
+
+    # Turn it into a shuffled iterable of pairs
+    pair = collections.namedtuple('pair', 'key value')
+    array = [pair(key=k, value=v) for k, v in mapping.items()]
+    random.shuffle(array)
+
+    # Confirm all values can be found
+    for key, value in mapping.items():
+        # Sync get
+        item = utils.get(array, key=key)
+        assert item is not None
+        assert item.value == value
+
+        # Async get
+        item = await utils.get(async_iterate(array), key=key)
+        assert item is not None
+        assert item.value == value
+
+        # Sync find
+        item = utils.find(lambda i: i.key == key, array)
+        assert item is not None
+        assert item.value == value
+
+        # Async find
+        item = await utils.find(lambda i: i.key == key, async_iterate(array))
+        assert item is not None
+        assert item.value == value
+
+
+def test_get_slots():
+    class A:
+        __slots__ = ('one', 'two')
+
+    class B(A):
+        __slots__ = ('three', 'four')
+
+    class C(B):
+        __slots__ = ('five', 'six')
+
+    assert set(utils.get_slots(C)) == {'one', 'two', 'three', 'four', 'five', 'six'}
+
+
+def test_valid_icon_size():
+    # Valid icon sizes
+    for size in [16, 32, 64, 128, 256, 512, 1024, 2048, 4096]:
+        assert utils.valid_icon_size(size)
+
+    # Some not valid icon sizes
+    for size in [-1, 0, 20, 103, 500, 8192]:
+        assert not utils.valid_icon_size(size)
+
+
+@pytest.mark.parametrize(
+    ('url', 'code'),
+    [
+        ('https://discordapp.com/invite/dpy', 'dpy'),
+        ('https://discord.com/invite/dpy', 'dpy'),
+        ('https://discord.gg/dpy', 'dpy'),
+    ]
+)
+def test_resolve_invite(url, code):
+    assert utils.resolve_invite(url) == code
+
+
+@pytest.mark.parametrize(
+    ('url', 'code'),
+    [
+        ('https://discordapp.com/template/foobar', 'foobar'),
+        ('https://discord.com/template/foobar', 'foobar'),
+        ('https://discord.new/foobar', 'foobar'),
+    ]
+)
+def test_resolve_template(url, code):
+    assert utils.resolve_template(url) == code
+
+
+@pytest.mark.parametrize(
+    'mention',
+    ['@everyone', '@here']
+)
+def test_escape_mentions(mention):
+    assert mention not in utils.escape_mentions(mention)
+    assert mention not in utils.escape_mentions(f"one {mention} two")
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ('source', 'chunk_size', 'chunked'),
+    [
+        ([1, 2, 3, 4, 5, 6], 2, [[1, 2], [3, 4], [5, 6]]),
+        ([1, 2, 3, 4, 5, 6], 3, [[1, 2, 3], [4, 5, 6]]),
+        ([1, 2, 3, 4, 5, 6], 4, [[1, 2, 3, 4], [5, 6]]),
+        ([1, 2, 3, 4, 5, 6], 5, [[1, 2, 3, 4, 5], [6]]),
+    ]
+)
+async def test_as_chunks(source, chunk_size, chunked):
+    assert [x for x in utils.as_chunks(source, chunk_size)] == chunked
+    assert [x async for x in utils.as_chunks(async_iterate(source), chunk_size)] == chunked
+
+
+@pytest.mark.parametrize(
+    ('annotation', 'resolved'),
+    [
+        (datetime.datetime, datetime.datetime),
+        ('datetime.datetime', datetime.datetime),
+        ('typing.Union[typing.Literal["a"], typing.Literal["b"]]', typing.Union[typing.Literal["a"], typing.Literal["b"]]),
+        ('typing.Union[typing.Union[int, str], typing.Union[bool, dict]]', typing.Union[int, str, bool, dict]),
+    ]
+)
+def test_resolve_annotation(annotation, resolved):
+    assert resolved == utils.resolve_annotation(annotation, globals(), locals(), None)
+
+
+@pytest.mark.skipif(sys.version_info < (3, 10), reason="3.10 union syntax")
+@pytest.mark.parametrize(
+    ('annotation', 'resolved'),
+    [
+        ('int | None', typing.Optional[int]),
+        ('str | int', typing.Union[str, int]),
+        ('str | int | None', typing.Optional[typing.Union[str, int]]),
+    ]
+)
+def test_resolve_annotation_310(annotation, resolved):
+    assert resolved == utils.resolve_annotation(annotation, globals(), locals(), None)
+
+
+# is_inside_class tests
+
+def not_a_class():
+    def not_a_class_either():
+        pass
+    return not_a_class_either
+
+class ThisIsAClass:
+    def in_a_class(self):
+        def not_directly_in_a_class():
+            pass
+        return not_directly_in_a_class
+
+    @classmethod
+    def a_class_method(cls):
+        def not_directly_in_a_class():
+            pass
+        return not_directly_in_a_class
+
+    @staticmethod
+    def a_static_method():
+        def not_directly_in_a_class():
+            pass
+        return not_directly_in_a_class
+
+    class SubClass:
+        pass
+
+
+def test_is_inside_class():
+    assert not utils.is_inside_class(not_a_class)
+    assert not utils.is_inside_class(not_a_class())
+    assert not utils.is_inside_class(ThisIsAClass)
+    assert utils.is_inside_class(ThisIsAClass.in_a_class)
+    assert utils.is_inside_class(ThisIsAClass.a_class_method)
+    assert utils.is_inside_class(ThisIsAClass.a_static_method)
+    assert not utils.is_inside_class(ThisIsAClass().in_a_class())
+    assert not utils.is_inside_class(ThisIsAClass.a_class_method())
+    assert not utils.is_inside_class(ThisIsAClass().a_static_method())
+    assert not utils.is_inside_class(ThisIsAClass.a_static_method())
+    # Only really designed for callables, although I guess it is callable due to the constructor
+    assert utils.is_inside_class(ThisIsAClass.SubClass)
+
+
+@pytest.mark.parametrize(
+    ('dt', 'style', 'formatted'),
+    [
+        (datetime.datetime(1970, 1, 1, 0, 0, 0, 0, tzinfo=datetime.timezone.utc), None, '<t:0>'),
+        (datetime.datetime(2020, 1, 1, 0, 0, 0, 0, tzinfo=datetime.timezone.utc), None, '<t:1577836800>'),
+        (datetime.datetime(2020, 1, 1, 0, 0, 0, 0, tzinfo=datetime.timezone.utc), 'F', '<t:1577836800:F>'),
+        (datetime.datetime(2033, 5, 18, 3, 33, 20, 0, tzinfo=datetime.timezone.utc), 'D', '<t:2000000000:D>'),
+    ]
+)
+def test_format_dt(dt: datetime.datetime, style: typing.Optional[utils.TimestampStyle], formatted: str):
+    assert utils.format_dt(dt, style=style) == formatted