diff --git a/bindings/python/tests/Makefile.am b/bindings/python/tests/Makefile.am
new file mode 100644
index 0000000..7dcdebb
--- /dev/null
+++ b/bindings/python/tests/Makefile.am
@@ -0,0 +1,17 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
+
+SUBDIRS = gpiosim
+
+EXTRA_DIST = \
+	helpers.py \
+	__init__.py \
+	__main__.py \
+	tests_chip_info.py \
+	tests_chip.py \
+	tests_edge_event.py \
+	tests_info_event.py \
+	tests_line_info.py \
+	tests_line_request.py \
+	tests_line_settings.py \
+	tests_module.py
diff --git a/bindings/python/tests/__init__.py b/bindings/python/tests/__init__.py
new file mode 100644
index 0000000..2bf14e6
--- /dev/null
+++ b/bindings/python/tests/__init__.py
@@ -0,0 +1,17 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
+
+import os
+import unittest
+
+from packaging import version
+
+required_kernel_version = "5.19.0"
+current_version = os.uname().release.split("-")[0]
+
+if version.parse(current_version) < version.parse(required_kernel_version):
+    raise NotImplementedError(
+        "linux kernel version must be at least {} - got {}".format(
+            required_kernel_version, current_version
+        )
+    )
diff --git a/bindings/python/tests/__main__.py b/bindings/python/tests/__main__.py
new file mode 100644
index 0000000..b5d7f0a
--- /dev/null
+++ b/bindings/python/tests/__main__.py
@@ -0,0 +1,16 @@
+#!/usr/bin/python3
+# SPDX-License-Identifier: GPL-2.0-or-later
+# SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
+
+import unittest
+
+from .tests_chip import *
+from .tests_chip_info import *
+from .tests_edge_event import *
+from .tests_info_event import *
+from .tests_line_info import *
+from .tests_line_settings import *
+from .tests_module import *
+from .tests_line_request import *
+
+unittest.main()
diff --git a/bindings/python/tests/gpiosim/Makefile.am b/bindings/python/tests/gpiosim/Makefile.am
new file mode 100644
index 0000000..7004f3a
--- /dev/null
+++ b/bindings/python/tests/gpiosim/Makefile.am
@@ -0,0 +1,7 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
+
+EXTRA_DIST = \
+	chip.py \
+	ext.c \
+	__init__.py
diff --git a/bindings/python/tests/gpiosim/__init__.py b/bindings/python/tests/gpiosim/__init__.py
new file mode 100644
index 0000000..f65e413
--- /dev/null
+++ b/bindings/python/tests/gpiosim/__init__.py
@@ -0,0 +1,4 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+# SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
+
+from .chip import Chip
diff --git a/bindings/python/tests/gpiosim/chip.py b/bindings/python/tests/gpiosim/chip.py
new file mode 100644
index 0000000..65c5af1
--- /dev/null
+++ b/bindings/python/tests/gpiosim/chip.py
@@ -0,0 +1,66 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+# SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
+
+from . import _ext
+from enum import Enum
+from typing import Optional
+
+
+class Chip:
+    """
+    Represents a simulated GPIO chip.
+    """
+
+    class Pull(Enum):
+        DOWN = _ext.PULL_DOWN
+        UP = _ext.PULL_UP
+
+    class Value(Enum):
+        INACTIVE = _ext.VALUE_INACTIVE
+        ACTIVE = _ext.VALUE_ACTIVE
+
+    class Direction(Enum):
+        INPUT = _ext.DIRECTION_INPUT
+        OUTPUT_HIGH = _ext.DIRECTION_OUTPUT_HIGH
+        OUTPUT_LOW = _ext.DIRECTION_OUTPUT_LOW
+
+    def __init__(
+        self,
+        /,
+        label: Optional[str] = None,
+        num_lines: Optional[int] = None,
+        line_names: Optional[dict[int, str]] = None,
+        hogs: Optional[dict[int, tuple[str, Direction]]] = None,
+    ):
+        self._chip = _ext.Chip()
+
+        if label:
+            self._chip.set_label(label)
+
+        if num_lines:
+            self._chip.set_num_lines(num_lines)
+
+        if line_names:
+            for off, name in line_names.items():
+                self._chip.set_line_name(off, name)
+
+        if hogs:
+            for off, (name, direction) in hogs.items():
+                self._chip.set_hog(off, name, direction.value)
+
+        self._chip.enable()
+
+    def get_value(self, offset: int) -> Value:
+        val = self._chip.get_value(offset)
+        return Chip.Value(val)
+
+    def set_pull(self, offset: int, pull: Pull) -> None:
+        self._chip.set_pull(offset, pull.value)
+
+    @property
+    def dev_path(self) -> str:
+        return self._chip.dev_path
+
+    @property
+    def name(self) -> str:
+        return self._chip.name
diff --git a/bindings/python/tests/gpiosim/ext.c b/bindings/python/tests/gpiosim/ext.c
new file mode 100644
index 0000000..6671352
--- /dev/null
+++ b/bindings/python/tests/gpiosim/ext.c
@@ -0,0 +1,345 @@
+// SPDX-License-Identifier: LGPL-2.1-or-later
+// SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
+
+#include <gpiosim.h>
+#include <Python.h>
+
+struct module_const {
+	const char *name;
+	long val;
+};
+
+static const struct module_const module_constants[] = {
+	{
+		.name = "PULL_DOWN",
+		.val = GPIOSIM_PULL_DOWN,
+	},
+	{
+		.name = "PULL_UP",
+		.val = GPIOSIM_PULL_UP,
+	},
+	{
+		.name = "VALUE_INACTIVE",
+		.val = GPIOSIM_VALUE_INACTIVE,
+	},
+	{
+		.name = "VALUE_ACTIVE",
+		.val = GPIOSIM_VALUE_ACTIVE,
+	},
+	{
+		.name = "DIRECTION_INPUT",
+		.val = GPIOSIM_HOG_DIR_INPUT,
+	},
+	{
+		.name = "DIRECTION_OUTPUT_HIGH",
+		.val = GPIOSIM_HOG_DIR_OUTPUT_HIGH,
+	},
+	{
+		.name = "DIRECTION_OUTPUT_LOW",
+		.val = GPIOSIM_HOG_DIR_OUTPUT_LOW,
+	},
+	{ }
+};
+
+struct module_state {
+	struct gpiosim_ctx *sim_ctx;
+};
+
+static void free_module_state(void *mod)
+{
+	struct module_state *state = PyModule_GetState((PyObject *)mod);
+
+	if (state->sim_ctx)
+		gpiosim_ctx_unref(state->sim_ctx);
+}
+
+static PyModuleDef module_def = {
+	PyModuleDef_HEAD_INIT,
+	.m_name = "gpiosim._ext",
+	.m_size = sizeof(struct module_state),
+	.m_free = free_module_state,
+};
+
+typedef struct {
+	PyObject_HEAD
+	struct gpiosim_dev *dev;
+	struct gpiosim_bank *bank;
+} chip_object;
+
+static int chip_init(chip_object *self,
+		     PyObject *Py_UNUSED(ignored0),
+		     PyObject *Py_UNUSED(ignored1))
+{
+	struct module_state *state;
+	PyObject *mod;
+
+	mod = PyState_FindModule(&module_def);
+	if (!mod)
+		return -1;
+
+	state = PyModule_GetState(mod);
+
+	self->dev = gpiosim_dev_new(state->sim_ctx);
+	if (!self->dev) {
+		PyErr_SetFromErrno(PyExc_OSError);
+		return -1;
+	}
+
+	self->bank = gpiosim_bank_new(self->dev);
+	if (!self->bank) {
+		PyErr_SetFromErrno(PyExc_OSError);
+		return -1;
+	}
+
+	return 0;
+}
+
+static void chip_finalize(chip_object *self)
+{
+	if (self->bank)
+		gpiosim_bank_unref(self->bank);
+
+	if (self->dev) {
+		if (gpiosim_dev_is_live(self->dev))
+			gpiosim_dev_disable(self->dev);
+
+		gpiosim_dev_unref(self->dev);
+	}
+}
+
+static void chip_dealloc(PyObject *self)
+{
+	int ret;
+
+	ret = PyObject_CallFinalizerFromDealloc(self);
+	if (ret < 0)
+		return;
+
+	PyObject_Del(self);
+}
+
+static PyObject *chip_dev_path(chip_object *self, void *Py_UNUSED(ignored))
+{
+	return PyUnicode_FromString(gpiosim_bank_get_dev_path(self->bank));
+}
+
+static PyObject *chip_name(chip_object *self, void *Py_UNUSED(ignored))
+{
+	return PyUnicode_FromString(gpiosim_bank_get_chip_name(self->bank));
+}
+
+static PyGetSetDef chip_getset[] = {
+	{
+		.name = "dev_path",
+		.get = (getter)chip_dev_path,
+	},
+	{
+		.name = "name",
+		.get = (getter)chip_name,
+	},
+	{ }
+};
+
+static PyObject *chip_set_label(chip_object *self, PyObject *args)
+{
+	const char *label;
+	int ret;
+
+	ret = PyArg_ParseTuple(args, "s", &label);
+	if (!ret)
+		return NULL;
+
+	ret = gpiosim_bank_set_label(self->bank, label);
+	if (ret)
+		return PyErr_SetFromErrno(PyExc_OSError);
+
+	Py_RETURN_NONE;
+}
+
+static PyObject *chip_set_num_lines(chip_object *self, PyObject *args)
+{
+	unsigned int num_lines;
+	int ret;
+
+	ret = PyArg_ParseTuple(args, "I", &num_lines);
+	if (!ret)
+		return NULL;
+
+	ret = gpiosim_bank_set_num_lines(self->bank, num_lines);
+	if (ret)
+		return PyErr_SetFromErrno(PyExc_OSError);
+
+	Py_RETURN_NONE;
+}
+
+static PyObject *chip_set_line_name(chip_object *self, PyObject *args)
+{
+	unsigned int offset;
+	const char *name;
+	int ret;
+
+	ret = PyArg_ParseTuple(args, "Is", &offset, &name);
+	if (!ret)
+		return NULL;
+
+	ret = gpiosim_bank_set_line_name(self->bank, offset, name);
+	if (ret)
+		return PyErr_SetFromErrno(PyExc_OSError);
+
+	Py_RETURN_NONE;
+}
+
+static PyObject *chip_set_hog(chip_object *self, PyObject *args)
+{
+	unsigned int offset;
+	const char *name;
+	int ret, dir;
+
+	ret = PyArg_ParseTuple(args, "Isi", &offset, &name, &dir);
+	if (!ret)
+		return NULL;
+
+	ret = gpiosim_bank_hog_line(self->bank, offset, name, dir);
+	if (ret)
+		return PyErr_SetFromErrno(PyExc_OSError);
+
+	Py_RETURN_NONE;
+}
+
+static PyObject *chip_enable(chip_object *self, PyObject *Py_UNUSED(args))
+{
+	int ret;
+
+	ret = gpiosim_dev_enable(self->dev);
+	if (ret)
+		return PyErr_SetFromErrno(PyExc_OSError);
+
+	Py_RETURN_NONE;
+}
+
+static PyObject *chip_get_value(chip_object *self, PyObject *args)
+{
+	unsigned int offset;
+	int ret, val;
+
+	ret = PyArg_ParseTuple(args, "I", &offset);
+	if (!ret)
+		return NULL;
+
+	val = gpiosim_bank_get_value(self->bank, offset);
+	if (val < 0)
+		return PyErr_SetFromErrno(PyExc_OSError);
+
+	return PyLong_FromLong(val);
+}
+
+static PyObject *chip_set_pull(chip_object *self, PyObject *args)
+{
+	unsigned int offset;
+	int ret, pull;
+
+	ret = PyArg_ParseTuple(args, "II", &offset, &pull);
+	if (!ret)
+		return NULL;
+
+	ret = gpiosim_bank_set_pull(self->bank, offset, pull);
+	if (ret)
+		return PyErr_SetFromErrno(PyExc_OSError);
+
+	Py_RETURN_NONE;
+}
+
+static PyMethodDef chip_methods[] = {
+	{
+		.ml_name = "set_label",
+		.ml_meth = (PyCFunction)chip_set_label,
+		.ml_flags = METH_VARARGS,
+	},
+	{
+		.ml_name = "set_num_lines",
+		.ml_meth = (PyCFunction)chip_set_num_lines,
+		.ml_flags = METH_VARARGS,
+	},
+	{
+		.ml_name = "set_line_name",
+		.ml_meth = (PyCFunction)chip_set_line_name,
+		.ml_flags = METH_VARARGS,
+	},
+	{
+		.ml_name = "set_hog",
+		.ml_meth = (PyCFunction)chip_set_hog,
+		.ml_flags = METH_VARARGS,
+	},
+	{
+		.ml_name = "enable",
+		.ml_meth = (PyCFunction)chip_enable,
+		.ml_flags = METH_NOARGS,
+	},
+	{
+		.ml_name = "get_value",
+		.ml_meth = (PyCFunction)chip_get_value,
+		.ml_flags = METH_VARARGS,
+	},
+	{
+		.ml_name = "set_pull",
+		.ml_meth = (PyCFunction)chip_set_pull,
+		.ml_flags = METH_VARARGS,
+	},
+	{ }
+};
+
+static PyTypeObject chip_type = {
+	PyVarObject_HEAD_INIT(NULL, 0)
+	.tp_name = "gpiosim.Chip",
+	.tp_basicsize = sizeof(chip_object),
+	.tp_flags = Py_TPFLAGS_DEFAULT,
+	.tp_new = PyType_GenericNew,
+	.tp_init = (initproc)chip_init,
+	.tp_finalize = (destructor)chip_finalize,
+	.tp_dealloc = (destructor)chip_dealloc,
+	.tp_methods = chip_methods,
+	.tp_getset = chip_getset,
+};
+
+PyMODINIT_FUNC PyInit__ext(void)
+{
+	const struct module_const *modconst;
+	struct module_state *state;
+	PyObject *module;
+	int ret;
+
+	module = PyModule_Create(&module_def);
+	if (!module)
+		return NULL;
+
+	ret = PyState_AddModule(module, &module_def);
+	if (ret) {
+		Py_DECREF(module);
+		return NULL;
+	}
+
+	state = PyModule_GetState(module);
+
+	state->sim_ctx = gpiosim_ctx_new();
+	if (!state->sim_ctx) {
+		Py_DECREF(module);
+		return PyErr_SetFromErrno(PyExc_OSError);
+	}
+
+	ret = PyModule_AddType(module, &chip_type);
+	if (ret) {
+		Py_DECREF(module);
+		return NULL;
+	}
+
+	for (modconst = module_constants; modconst->name; modconst++) {
+		ret = PyModule_AddIntConstant(module,
+					      modconst->name, modconst->val);
+		if (ret) {
+			Py_DECREF(module);
+			return NULL;
+		}
+ 	}
+
+	return module;
+}
diff --git a/bindings/python/tests/helpers.py b/bindings/python/tests/helpers.py
new file mode 100644
index 0000000..f9a15e8
--- /dev/null
+++ b/bindings/python/tests/helpers.py
@@ -0,0 +1,16 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+# SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
+
+import os
+
+
+class LinkGuard:
+    def __init__(self, src, dst):
+        self.src = src
+        self.dst = dst
+
+    def __enter__(self):
+        os.symlink(self.src, self.dst)
+
+    def __exit__(self, type, val, tb):
+        os.unlink(self.dst)
diff --git a/bindings/python/tests/tests_chip.py b/bindings/python/tests/tests_chip.py
new file mode 100644
index 0000000..0d1effe
--- /dev/null
+++ b/bindings/python/tests/tests_chip.py
@@ -0,0 +1,231 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+# SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
+
+import errno
+import gpiod
+import os
+
+from . import gpiosim
+from .helpers import LinkGuard
+from unittest import TestCase
+
+
+class ChipConstructor(TestCase):
+    def test_open_existing_chip(self):
+        sim = gpiosim.Chip()
+
+        with gpiod.Chip(sim.dev_path):
+            pass
+
+    def test_open_existing_chip_with_keyword(self):
+        sim = gpiosim.Chip()
+
+        with gpiod.Chip(path=sim.dev_path):
+            pass
+
+    def test_open_chip_by_link(self):
+        link = "/tmp/gpiod-py-test-link.{}".format(os.getpid())
+        sim = gpiosim.Chip()
+
+        with LinkGuard(sim.dev_path, link):
+            with gpiod.Chip(link):
+                pass
+
+    def test_open_nonexistent_chip(self):
+        with self.assertRaises(OSError) as ex:
+            gpiod.Chip("/dev/nonexistent")
+
+        self.assertEqual(ex.exception.errno, errno.ENOENT)
+
+    def test_open_not_a_character_device(self):
+        with self.assertRaises(OSError) as ex:
+            gpiod.Chip("/tmp")
+
+        self.assertEqual(ex.exception.errno, errno.ENOTTY)
+
+    def test_open_not_a_gpio_device(self):
+        with self.assertRaises(OSError) as ex:
+            gpiod.Chip("/dev/null")
+
+        self.assertEqual(ex.exception.errno, errno.ENODEV)
+
+    def test_missing_path(self):
+        with self.assertRaises(TypeError):
+            gpiod.Chip()
+
+    def test_invalid_type_for_path(self):
+        with self.assertRaises(TypeError):
+            gpiod.Chip(4)
+
+
+class ChipBooleanConversion(TestCase):
+    def test_chip_bool(self):
+        sim = gpiosim.Chip()
+        chip = gpiod.Chip(sim.dev_path)
+        self.assertTrue(chip)
+        chip.close()
+        self.assertFalse(chip)
+
+
+class ChipProperties(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip()
+        self.chip = gpiod.Chip(self.sim.dev_path)
+
+    def tearDown(self):
+        self.chip.close()
+        self.sim = None
+
+    def test_get_chip_path(self):
+        self.assertEqual(self.sim.dev_path, self.chip.path)
+
+    def test_get_fd(self):
+        self.assertGreaterEqual(self.chip.fd, 0)
+
+    def test_properties_are_immutable(self):
+        with self.assertRaises(AttributeError):
+            self.chip.path = "foobar"
+
+        with self.assertRaises(AttributeError):
+            self.chip.fd = 4
+
+
+class ChipDevPathFromLink(TestCase):
+    def test_dev_path_open_by_link(self):
+        sim = gpiosim.Chip()
+        link = "/tmp/gpiod-py-test-link.{}".format(os.getpid())
+
+        with LinkGuard(sim.dev_path, link):
+            with gpiod.Chip(link) as chip:
+                self.assertEqual(chip.path, link)
+
+
+class ChipMapLine(TestCase):
+    def test_lookup_by_name_good(self):
+        sim = gpiosim.Chip(
+            num_lines=8, line_names={1: "foo", 2: "bar", 4: "baz", 5: "xyz"}
+        )
+
+        with gpiod.Chip(sim.dev_path) as chip:
+            self.assertEqual(chip.map_line("baz"), 4)
+
+    def test_lookup_by_name_good_keyword_argument(self):
+        sim = gpiosim.Chip(
+            num_lines=8, line_names={1: "foo", 2: "bar", 4: "baz", 5: "xyz"}
+        )
+
+        with gpiod.Chip(sim.dev_path) as chip:
+            self.assertEqual(chip.map_line(id="baz"), 4)
+
+    def test_lookup_bad_name(self):
+        sim = gpiosim.Chip(
+            num_lines=8, line_names={1: "foo", 2: "bar", 4: "baz", 5: "xyz"}
+        )
+
+        with gpiod.Chip(sim.dev_path) as chip:
+            with self.assertRaises(FileNotFoundError):
+                chip.map_line("nonexistent")
+
+    def test_lookup_bad_offset(self):
+        sim = gpiosim.Chip()
+
+        with gpiod.Chip(sim.dev_path) as chip:
+            with self.assertRaises(ValueError):
+                chip.map_line(4)
+
+    def test_lookup_bad_offset_as_string(self):
+        sim = gpiosim.Chip()
+
+        with gpiod.Chip(sim.dev_path) as chip:
+            with self.assertRaises(ValueError):
+                chip.map_line("4")
+
+    def test_duplicate_names(self):
+        sim = gpiosim.Chip(
+            num_lines=8, line_names={1: "foo", 2: "bar", 4: "baz", 5: "bar"}
+        )
+
+        with gpiod.Chip(sim.dev_path) as chip:
+            self.assertEqual(chip.map_line("bar"), 2)
+
+    def test_integer_offsets(self):
+        sim = gpiosim.Chip(num_lines=8, line_names={1: "foo", 2: "bar", 6: "baz"})
+
+        with gpiod.Chip(sim.dev_path) as chip:
+            self.assertEqual(chip.map_line(4), 4)
+            self.assertEqual(chip.map_line(1), 1)
+
+    def test_offsets_as_string(self):
+        sim = gpiosim.Chip(num_lines=8, line_names={1: "foo", 2: "bar", 7: "6"})
+
+        with gpiod.Chip(sim.dev_path) as chip:
+            self.assertEqual(chip.map_line("2"), 2)
+            self.assertEqual(chip.map_line("6"), 7)
+
+
+class ClosedChipCannotBeUsed(TestCase):
+    def test_close_chip_and_try_to_use_it(self):
+        sim = gpiosim.Chip(label="foobar")
+
+        chip = gpiod.Chip(sim.dev_path)
+        chip.close()
+
+        with self.assertRaises(gpiod.ChipClosedError):
+            chip.path
+
+    def test_close_chip_and_try_controlled_execution(self):
+        sim = gpiosim.Chip()
+
+        chip = gpiod.Chip(sim.dev_path)
+        chip.close()
+
+        with self.assertRaises(gpiod.ChipClosedError):
+            with chip:
+                chip.fd
+
+    def test_close_chip_twice(self):
+        sim = gpiosim.Chip(label="foobar")
+        chip = gpiod.Chip(sim.dev_path)
+        chip.close()
+
+        with self.assertRaises(gpiod.ChipClosedError):
+            chip.close()
+
+
+class StringRepresentation(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=4, label="foobar")
+        self.chip = gpiod.Chip(self.sim.dev_path)
+
+    def tearDown(self):
+        self.chip.close()
+        self.sim = None
+
+    def test_repr(self):
+        self.assertEqual(repr(self.chip), 'Chip("{}")'.format(self.sim.dev_path))
+
+    def test_str(self):
+        info = self.chip.get_info()
+        self.assertEqual(
+            str(self.chip),
+            '<Chip path="{}" fd={} info=<ChipInfo name="{}" label="foobar" num_lines=4>>'.format(
+                self.sim.dev_path, self.chip.fd, info.name
+            ),
+        )
+
+
+class StringRepresentationClosed(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=4, label="foobar")
+        self.chip = gpiod.Chip(self.sim.dev_path)
+
+    def tearDown(self):
+        self.sim = None
+
+    def test_repr_closed(self):
+        self.chip.close()
+        self.assertEqual(repr(self.chip), "<Chip CLOSED>")
+
+    def test_str_closed(self):
+        self.chip.close()
+        self.assertEqual(str(self.chip), "<Chip CLOSED>")
diff --git a/bindings/python/tests/tests_chip_info.py b/bindings/python/tests/tests_chip_info.py
new file mode 100644
index 0000000..d392ec3
--- /dev/null
+++ b/bindings/python/tests/tests_chip_info.py
@@ -0,0 +1,52 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+# SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
+
+import gpiod
+
+from . import gpiosim
+from unittest import TestCase
+
+
+class ChipInfoProperties(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(label="foobar", num_lines=16)
+        self.chip = gpiod.Chip(self.sim.dev_path)
+        self.info = self.chip.get_info()
+
+    def tearDown(self):
+        self.info = None
+        self.chip.close()
+        self.chip = None
+        self.sim = None
+
+    def test_chip_info_name(self):
+        self.assertEqual(self.info.name, self.sim.name)
+
+    def test_chip_info_label(self):
+        self.assertEqual(self.info.label, "foobar")
+
+    def test_chip_info_num_lines(self):
+        self.assertEqual(self.info.num_lines, 16)
+
+    def test_chip_info_properties_are_immutable(self):
+        with self.assertRaises(AttributeError):
+            self.info.name = "foobar"
+
+        with self.assertRaises(AttributeError):
+            self.info.num_lines = 4
+
+        with self.assertRaises(AttributeError):
+            self.info.label = "foobar"
+
+
+class ChipInfoStringRepresentation(TestCase):
+    def test_chip_info_str(self):
+        sim = gpiosim.Chip(label="foobar", num_lines=16)
+
+        with gpiod.Chip(sim.dev_path) as chip:
+            info = chip.get_info()
+
+            self.assertEqual(
+                str(info),
+                '<ChipInfo name="{}" label="foobar" num_lines=16>'.format(sim.name),
+            )
diff --git a/bindings/python/tests/tests_edge_event.py b/bindings/python/tests/tests_edge_event.py
new file mode 100644
index 0000000..8d52fdd
--- /dev/null
+++ b/bindings/python/tests/tests_edge_event.py
@@ -0,0 +1,219 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+# SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
+
+import gpiod
+import time
+
+from . import gpiosim
+from datetime import timedelta
+from functools import partial
+from gpiod.line import Direction, Edge
+from threading import Thread
+from unittest import TestCase
+
+EventType = gpiod.EdgeEvent.Type
+Pull = gpiosim.Chip.Pull
+
+
+class EdgeEventWaitTimeout(TestCase):
+    def test_event_wait_timeout(self):
+        sim = gpiosim.Chip()
+
+        with gpiod.request_lines(
+            sim.dev_path,
+            {0: gpiod.LineSettings(edge_detection=Edge.BOTH)},
+        ) as req:
+            self.assertEqual(req.wait_edge_event(timedelta(microseconds=10000)), False)
+
+    def test_event_wait_timeout_float(self):
+        sim = gpiosim.Chip()
+
+        with gpiod.request_lines(
+            sim.dev_path,
+            {0: gpiod.LineSettings(edge_detection=Edge.BOTH)},
+        ) as req:
+            self.assertEqual(req.wait_edge_event(0.01), False)
+
+
+class EdgeEventInvalidConfig(TestCase):
+    def test_output_mode_and_edge_detection(self):
+        sim = gpiosim.Chip()
+
+        with self.assertRaises(ValueError):
+            gpiod.request_lines(
+                sim.dev_path,
+                {
+                    0: gpiod.LineSettings(
+                        direction=Direction.OUTPUT, edge_detection=Edge.BOTH
+                    )
+                },
+            )
+
+
+class WaitingForEdgeEvents(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=8)
+        self.thread = None
+
+    def tearDown(self):
+        if self.thread:
+            self.thread.join()
+            del self.thread
+        self.sim = None
+
+    def trigger_falling_and_rising_edge(self, offset):
+        time.sleep(0.05)
+        self.sim.set_pull(offset, Pull.UP)
+        time.sleep(0.05)
+        self.sim.set_pull(offset, Pull.DOWN)
+
+    def trigger_rising_edge_events_on_two_offsets(self, offset0, offset1):
+        time.sleep(0.05)
+        self.sim.set_pull(offset0, Pull.UP)
+        time.sleep(0.05)
+        self.sim.set_pull(offset1, Pull.UP)
+
+    def test_both_edge_events(self):
+        with gpiod.request_lines(
+            self.sim.dev_path, {2: gpiod.LineSettings(edge_detection=Edge.BOTH)}
+        ) as req:
+            self.thread = Thread(
+                target=partial(self.trigger_falling_and_rising_edge, 2)
+            )
+            self.thread.start()
+
+            self.assertTrue(req.wait_edge_event(timedelta(seconds=1)))
+            events = req.read_edge_event()
+            self.assertEqual(len(events), 1)
+            event = events[0]
+            self.assertEqual(event.event_type, EventType.RISING_EDGE)
+            self.assertEqual(event.line_offset, 2)
+            ts_rising = event.timestamp_ns
+
+            self.assertTrue(req.wait_edge_event(timedelta(seconds=1)))
+            events = req.read_edge_event()
+            self.assertEqual(len(events), 1)
+            event = events[0]
+            self.assertEqual(event.event_type, EventType.FALLING_EDGE)
+            self.assertEqual(event.line_offset, 2)
+            ts_falling = event.timestamp_ns
+
+            self.assertGreater(ts_falling, ts_rising)
+
+    def test_rising_edge_event(self):
+        with gpiod.request_lines(
+            self.sim.dev_path, {6: gpiod.LineSettings(edge_detection=Edge.RISING)}
+        ) as req:
+            self.thread = Thread(
+                target=partial(self.trigger_falling_and_rising_edge, 6)
+            )
+            self.thread.start()
+
+            self.assertTrue(req.wait_edge_event(timedelta(seconds=1)))
+            events = req.read_edge_event()
+            self.assertEqual(len(events), 1)
+            event = events[0]
+            self.assertEqual(event.event_type, EventType.RISING_EDGE)
+            self.assertEqual(event.line_offset, 6)
+
+            self.assertFalse(req.wait_edge_event(timedelta(microseconds=10000)))
+
+    def test_rising_edge_event(self):
+        with gpiod.request_lines(
+            self.sim.dev_path, {6: gpiod.LineSettings(edge_detection=Edge.FALLING)}
+        ) as req:
+            self.thread = Thread(
+                target=partial(self.trigger_falling_and_rising_edge, 6)
+            )
+            self.thread.start()
+
+            self.assertTrue(req.wait_edge_event(timedelta(seconds=1)))
+            events = req.read_edge_event()
+            self.assertEqual(len(events), 1)
+            event = events[0]
+            self.assertEqual(event.event_type, EventType.FALLING_EDGE)
+            self.assertEqual(event.line_offset, 6)
+
+            self.assertFalse(req.wait_edge_event(timedelta(microseconds=10000)))
+
+    def test_sequence_numbers(self):
+        with gpiod.request_lines(
+            self.sim.dev_path, {(2, 4): gpiod.LineSettings(edge_detection=Edge.BOTH)}
+        ) as req:
+            self.thread = Thread(
+                target=partial(self.trigger_rising_edge_events_on_two_offsets, 2, 4)
+            )
+            self.thread.start()
+
+            self.assertTrue(req.wait_edge_event(timedelta(seconds=1)))
+            events = req.read_edge_event()
+            self.assertEqual(len(events), 1)
+            event = events[0]
+            self.assertEqual(event.event_type, EventType.RISING_EDGE)
+            self.assertEqual(event.line_offset, 2)
+            self.assertEqual(event.global_seqno, 1)
+            self.assertEqual(event.line_seqno, 1)
+
+            self.assertTrue(req.wait_edge_event(timedelta(seconds=1)))
+            events = req.read_edge_event()
+            self.assertEqual(len(events), 1)
+            event = events[0]
+            self.assertEqual(event.event_type, EventType.RISING_EDGE)
+            self.assertEqual(event.line_offset, 4)
+            self.assertEqual(event.global_seqno, 2)
+            self.assertEqual(event.line_seqno, 1)
+
+
+class ReadingMultipleEdgeEvents(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=8)
+        self.request = gpiod.request_lines(
+            self.sim.dev_path, {1: gpiod.LineSettings(edge_detection=Edge.BOTH)}
+        )
+        self.line_seqno = 1
+        self.global_seqno = 1
+        self.sim.set_pull(1, Pull.UP)
+        time.sleep(0.05)
+        self.sim.set_pull(1, Pull.DOWN)
+        time.sleep(0.05)
+        self.sim.set_pull(1, Pull.UP)
+        time.sleep(0.05)
+
+    def tearDown(self):
+        self.request.release()
+        del self.request
+        del self.sim
+
+    def test_read_multiple_events(self):
+        self.assertTrue(self.request.wait_edge_event(timedelta(seconds=1)))
+        events = self.request.read_edge_event()
+        self.assertEqual(len(events), 3)
+
+        for event in events:
+            self.assertEqual(event.line_offset, 1)
+            self.assertEqual(event.line_seqno, self.line_seqno)
+            self.assertEqual(event.global_seqno, self.global_seqno)
+            self.line_seqno += 1
+            self.global_seqno += 1
+
+    # TODO buffer capacity
+    # def test_read_over_buffer_capacity(self):
+    #     buf = gpiod.EdgeEventBuffer(2)
+    #     self.assertTrue(self.request.wait_edge_event(datetime.timedelta(seconds=1)))
+    #     self.assertEqual(self.request.read_edge_event(buf), 2)
+    #     self.assertEqual(len(buf), 2)
+
+
+class EdgeEventStringRepresentation(TestCase):
+    def test_edge_event_str(self):
+        sim = gpiosim.Chip()
+
+        with gpiod.request_lines(
+            path=sim.dev_path, config={0: gpiod.LineSettings(edge_detection=Edge.BOTH)}
+        ) as req:
+            sim.set_pull(0, Pull.UP)
+            event = req.read_edge_event()[0]
+            self.assertRegex(
+                str(event),
+                "<EdgeEvent type=Type\.RISING_EDGE timestamp_ns=[0-9]+ line_offset=0 global_seqno=1 line_seqno=1>",
+            )
diff --git a/bindings/python/tests/tests_info_event.py b/bindings/python/tests/tests_info_event.py
new file mode 100644
index 0000000..f3926d9
--- /dev/null
+++ b/bindings/python/tests/tests_info_event.py
@@ -0,0 +1,189 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+# SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
+
+import datetime
+import errno
+import gpiod
+import threading
+import time
+import unittest
+
+from . import gpiosim
+from dataclasses import FrozenInstanceError
+from functools import partial
+from gpiod.line import Direction
+from unittest import TestCase
+
+EventType = gpiod.InfoEvent.Type
+
+
+class InfoEventDataclassBehavior(TestCase):
+    def test_info_event_props_are_frozen(self):
+        sim = gpiosim.Chip()
+
+        with gpiod.Chip(sim.dev_path) as chip:
+            chip.watch_line_info(0)
+            with chip.request_lines(config={0: None}) as request:
+                self.assertTrue(chip.wait_info_event(datetime.timedelta(seconds=1)))
+                event = chip.read_info_event()
+
+                with self.assertRaises(FrozenInstanceError):
+                    event.event_type = 4
+
+                with self.assertRaises(FrozenInstanceError):
+                    event.timestamp_ns = 4
+
+                with self.assertRaises(FrozenInstanceError):
+                    event.line_info = 4
+
+
+def request_reconfigure_release_line(chip, offset):
+    time.sleep(0.1)
+    with chip.request_lines(config={offset: None}) as request:
+        time.sleep(0.1)
+        request.reconfigure_lines(
+            config={offset: gpiod.LineSettings(direction=Direction.OUTPUT)}
+        )
+        time.sleep(0.1)
+
+
+class WatchingInfoEventWorks(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=8, line_names={4: "foobar"})
+        self.chip = gpiod.Chip(self.sim.dev_path)
+        self.thread = None
+
+    def tearDown(self):
+        if self.thread:
+            self.thread.join()
+            self.thread = None
+
+        self.chip.close()
+        self.chip = None
+        self.sim = None
+
+    def test_watch_line_info_returns_line_info(self):
+        info = self.chip.watch_line_info(7)
+        self.assertEqual(info.offset, 7)
+
+    def test_watch_line_info_keyword_argument(self):
+        info = self.chip.watch_line_info(line=7)
+
+    def test_watch_line_info_offset_out_of_range(self):
+        with self.assertRaises(ValueError):
+            self.chip.watch_line_info(8)
+
+    def test_watch_line_info_no_arguments(self):
+        with self.assertRaises(TypeError):
+            self.chip.watch_line_info()
+
+    def test_watch_line_info_by_line_name(self):
+        self.chip.watch_line_info("foobar")
+
+    def test_watch_line_info_invalid_argument_type(self):
+        with self.assertRaises(TypeError):
+            self.chip.watch_line_info(None)
+
+    def test_wait_for_event_timeout(self):
+        info = self.chip.watch_line_info(7)
+        self.assertFalse(
+            self.chip.wait_info_event(datetime.timedelta(microseconds=10000))
+        )
+
+    def test_request_reconfigure_release_events(self):
+        info = self.chip.watch_line_info(7)
+        self.assertEqual(info.direction, Direction.INPUT)
+
+        self.thread = threading.Thread(
+            target=partial(request_reconfigure_release_line, self.chip, 7)
+        )
+        self.thread.start()
+
+        self.assertTrue(self.chip.wait_info_event(datetime.timedelta(seconds=1)))
+        event = self.chip.read_info_event()
+        self.assertEqual(event.event_type, EventType.LINE_REQUESTED)
+        self.assertEqual(event.line_info.offset, 7)
+        self.assertEqual(event.line_info.direction, Direction.INPUT)
+        ts_req = event.timestamp_ns
+
+        # Check that we can use a float directly instead of datetime.timedelta.
+        self.assertTrue(self.chip.wait_info_event(1.0))
+        event = self.chip.read_info_event()
+        self.assertEqual(event.event_type, EventType.LINE_CONFIG_CHANGED)
+        self.assertEqual(event.line_info.offset, 7)
+        self.assertEqual(event.line_info.direction, Direction.OUTPUT)
+        ts_rec = event.timestamp_ns
+
+        self.assertTrue(self.chip.wait_info_event(datetime.timedelta(seconds=1)))
+        event = self.chip.read_info_event()
+        self.assertEqual(event.event_type, EventType.LINE_RELEASED)
+        self.assertEqual(event.line_info.offset, 7)
+        self.assertEqual(event.line_info.direction, Direction.OUTPUT)
+        ts_rel = event.timestamp_ns
+
+        # No more events.
+        self.assertFalse(
+            self.chip.wait_info_event(datetime.timedelta(microseconds=10000))
+        )
+
+        # Check timestamps are really monotonic.
+        self.assertGreater(ts_rel, ts_rec)
+        self.assertGreater(ts_rec, ts_req)
+
+
+class UnwatchingLineInfo(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=8, line_names={4: "foobar"})
+        self.chip = gpiod.Chip(self.sim.dev_path)
+
+    def tearDown(self):
+        self.chip.close()
+        self.chip = None
+        self.sim = None
+
+    def test_unwatch_line_info(self):
+        self.chip.watch_line_info(0)
+        with self.chip.request_lines(config={0: None}) as request:
+            self.assertTrue(self.chip.wait_info_event(datetime.timedelta(seconds=1)))
+            event = self.chip.read_info_event()
+            self.assertEqual(event.event_type, EventType.LINE_REQUESTED)
+            self.chip.unwatch_line_info(0)
+
+        self.assertFalse(
+            self.chip.wait_info_event(datetime.timedelta(microseconds=10000))
+        )
+
+    def test_unwatch_not_watched_line(self):
+        with self.assertRaises(OSError) as ex:
+            self.chip.unwatch_line_info(2)
+
+        self.assertEqual(ex.exception.errno, errno.EBUSY)
+
+    def test_unwatch_line_info_no_argument(self):
+        with self.assertRaises(TypeError):
+            self.chip.unwatch_line_info()
+
+    def test_unwatch_line_info_by_line_name(self):
+        self.chip.watch_line_info(4)
+        with self.chip.request_lines(config={4: None}) as request:
+            self.assertIsNotNone(self.chip.read_info_event())
+            self.chip.unwatch_line_info("foobar")
+
+        self.assertFalse(
+            self.chip.wait_info_event(datetime.timedelta(microseconds=10000))
+        )
+
+
+class InfoEventStringRepresentation(TestCase):
+    def test_info_event_str(self):
+        sim = gpiosim.Chip()
+
+        with gpiod.Chip(sim.dev_path) as chip:
+            chip.watch_line_info(0)
+            with chip.request_lines(config={0: None}) as request:
+                self.assertTrue(chip.wait_info_event(datetime.timedelta(seconds=1)))
+                event = chip.read_info_event()
+                self.assertRegex(
+                    str(event),
+                    '<InfoEvent type=Type\.LINE_REQUESTED timestamp_ns=[0-9]+ line_info=<LineInfo offset=0 name="None" used=True consumer="\?" direction=Direction\.INPUT active_low=False bias=Bias\.UNKNOWN drive=Drive\.PUSH_PULL edge_detection=Edge\.NONE event_clock=Clock\.MONOTONIC debounced=False debounce_period=0:00:00>>',
+                )
diff --git a/bindings/python/tests/tests_line_info.py b/bindings/python/tests/tests_line_info.py
new file mode 100644
index 0000000..2779e7a
--- /dev/null
+++ b/bindings/python/tests/tests_line_info.py
@@ -0,0 +1,101 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+# SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
+
+import errno
+import gpiod
+import unittest
+
+from . import gpiosim
+from gpiod.line import Direction, Bias, Drive, Clock
+
+HogDir = gpiosim.Chip.Direction
+
+
+class GetLineInfo(unittest.TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(
+            num_lines=4,
+            line_names={0: "foobar"},
+        )
+
+        self.chip = gpiod.Chip(self.sim.dev_path)
+
+    def tearDown(self):
+        self.chip.close()
+        self.chip = None
+        self.sim = None
+
+    def test_get_line_info_by_offset(self):
+        self.chip.get_line_info(0)
+
+    def test_get_line_info_by_offset_keyword(self):
+        self.chip.get_line_info(line=0)
+
+    def test_get_line_info_by_name(self):
+        self.chip.get_line_info("foobar")
+
+    def test_get_line_info_by_name_keyword(self):
+        self.chip.get_line_info(line="foobar")
+
+    def test_get_line_info_by_offset_string(self):
+        self.chip.get_line_info("2")
+
+    def test_offset_out_of_range(self):
+        with self.assertRaises(ValueError) as ex:
+            self.chip.get_line_info(4)
+
+    def test_no_offset(self):
+        with self.assertRaises(TypeError):
+            self.chip.get_line_info()
+
+
+class LinePropertiesCanBeRead(unittest.TestCase):
+    def test_basic_properties(self):
+        sim = gpiosim.Chip(
+            num_lines=8,
+            line_names={1: "foo", 2: "bar", 4: "baz", 5: "xyz"},
+            hogs={3: ("hog3", HogDir.OUTPUT_HIGH), 4: ("hog4", HogDir.OUTPUT_LOW)},
+        )
+
+        with gpiod.Chip(sim.dev_path) as chip:
+            info4 = chip.get_line_info(4)
+            info6 = chip.get_line_info(6)
+
+            self.assertEqual(info4.offset, 4)
+            self.assertEqual(info4.name, "baz")
+            self.assertTrue(info4.used)
+            self.assertEqual(info4.consumer, "hog4")
+            self.assertEqual(info4.direction, Direction.OUTPUT)
+            self.assertFalse(info4.active_low)
+            self.assertEqual(info4.bias, Bias.UNKNOWN)
+            self.assertEqual(info4.drive, Drive.PUSH_PULL)
+            self.assertEqual(info4.event_clock, Clock.MONOTONIC)
+            self.assertFalse(info4.debounced)
+            self.assertEqual(info4.debounce_period.total_seconds(), 0.0)
+
+            self.assertEqual(info6.offset, 6)
+            self.assertEqual(info6.name, None)
+            self.assertFalse(info6.used)
+            self.assertEqual(info6.consumer, None)
+            self.assertEqual(info6.direction, Direction.INPUT)
+            self.assertFalse(info6.active_low)
+            self.assertEqual(info6.bias, Bias.UNKNOWN)
+            self.assertEqual(info6.drive, Drive.PUSH_PULL)
+            self.assertEqual(info6.event_clock, Clock.MONOTONIC)
+            self.assertFalse(info6.debounced)
+            self.assertEqual(info6.debounce_period.total_seconds(), 0.0)
+
+
+class LineInfoStringRepresentation(unittest.TestCase):
+    def test_line_info_str(self):
+        sim = gpiosim.Chip(
+            line_names={0: "foo"}, hogs={0: ("hogger", HogDir.OUTPUT_HIGH)}
+        )
+
+        with gpiod.Chip(sim.dev_path) as chip:
+            info = chip.get_line_info(0)
+
+            self.assertEqual(
+                str(info),
+                '<LineInfo offset=0 name="foo" used=True consumer="hogger" direction=Direction.OUTPUT active_low=False bias=Bias.UNKNOWN drive=Drive.PUSH_PULL edge_detection=Edge.NONE event_clock=Clock.MONOTONIC debounced=False debounce_period=0:00:00>',
+            )
diff --git a/bindings/python/tests/tests_line_request.py b/bindings/python/tests/tests_line_request.py
new file mode 100644
index 0000000..045ace6
--- /dev/null
+++ b/bindings/python/tests/tests_line_request.py
@@ -0,0 +1,449 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+# SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
+
+import errno
+import gpiod
+
+from . import gpiosim
+from gpiod.line import Direction, Edge, Value
+from unittest import TestCase
+
+Pull = gpiosim.Chip.Pull
+SimVal = gpiosim.Chip.Value
+
+
+class ChipLineRequestsBehaveCorrectlyWithInvalidArguments(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=8)
+        self.chip = gpiod.Chip(self.sim.dev_path)
+
+    def tearDown(self):
+        self.chip.close()
+        del self.chip
+        del self.sim
+
+    def test_passing_invalid_types_as_configs(self):
+        with self.assertRaises(AttributeError):
+            self.chip.request_lines("foobar")
+
+        with self.assertRaises(AttributeError):
+            self.chip.request_lines(None, "foobar")
+
+    def test_duplicate_offsets(self):
+        with self.chip.request_lines(config={(2, 5, 1, 7, 5): None}) as req:
+            self.assertEqual(req.offsets, [2, 5, 1, 7])
+
+    def test_offset_out_of_range(self):
+        with self.assertRaises(ValueError):
+            self.chip.request_lines(config={(1, 0, 4, 8): None})
+
+    def test_line_name_not_found(self):
+        with self.assertRaises(FileNotFoundError):
+            self.chip.request_lines(config={"foo": None})
+
+    def test_request_no_arguments(self):
+        with self.assertRaises(TypeError):
+            self.chip.request_lines()
+
+
+class ModuleLineRequestsBehaveCorrectlyWithInvalidArguments(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=8)
+
+    def tearDown(self):
+        del self.sim
+
+    def test_passing_invalid_types_as_configs(self):
+        with self.assertRaises(AttributeError):
+            gpiod.request_lines(self.sim.dev_path, "foobar")
+
+        with self.assertRaises(AttributeError):
+            gpiod.request_lines(self.sim.dev_path, None, "foobar")
+
+    def test_duplicate_offsets(self):
+        with gpiod.request_lines(
+            self.sim.dev_path, config={(2, 5, 1, 7, 5): None}
+        ) as req:
+            self.assertEqual(req.offsets, [2, 5, 1, 7])
+
+    def test_offset_out_of_range(self):
+        with self.assertRaises(ValueError):
+            gpiod.request_lines(self.sim.dev_path, config={(1, 0, 4, 8): None})
+
+    def test_line_name_not_found(self):
+        with self.assertRaises(FileNotFoundError):
+            gpiod.request_lines(self.sim.dev_path, config={"foo": None})
+
+    def test_request_no_arguments(self):
+        with self.assertRaises(TypeError):
+            gpiod.request_lines()
+
+
+class ChipLineRequestWorks(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=8, line_names={5: "foo", 7: "bar"})
+        self.chip = gpiod.Chip(self.sim.dev_path)
+
+    def tearDown(self):
+        self.chip.close()
+        del self.chip
+        del self.sim
+
+    def test_request_with_positional_arguments(self):
+        with self.chip.request_lines({(0, 5, 3, 1): None}, "foobar", 32) as req:
+            self.assertEqual(req.offsets, [0, 5, 3, 1])
+            self.assertEqual(self.chip.get_line_info(0).consumer, "foobar")
+
+    def test_request_with_keyword_arguments(self):
+        with self.chip.request_lines(
+            config={(0, 5, 6): None},
+            consumer="foobar",
+            event_buffer_size=16,
+        ) as req:
+            self.assertEqual(req.offsets, [0, 5, 6])
+            self.assertEqual(self.chip.get_line_info(0).consumer, "foobar")
+
+    def test_request_single_offset_as_int(self):
+        with self.chip.request_lines(config={4: None}) as req:
+            self.assertEqual(req.offsets, [4])
+
+    def test_request_single_offset_as_tuple(self):
+        with self.chip.request_lines(config={(4): None}) as req:
+            self.assertEqual(req.offsets, [4])
+
+    def test_request_by_name(self):
+        with self.chip.request_lines(config={(1, 2, "foo", "bar"): None}) as req:
+            self.assertEqual(req.offsets, [1, 2, 5, 7])
+
+    def test_request_single_line_by_name(self):
+        with self.chip.request_lines(config={"foo": None}) as req:
+            self.assertEqual(req.offsets, [5])
+
+
+class ModuleLineRequestWorks(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=8, line_names={5: "foo", 7: "bar"})
+
+    def tearDown(self):
+        del self.sim
+
+    def test_request_with_positional_arguments(self):
+        with gpiod.request_lines(
+            self.sim.dev_path, {(0, 5, 3, 1): None}, "foobar", 32
+        ) as req:
+            self.assertEqual(req.offsets, [0, 5, 3, 1])
+            with gpiod.Chip(self.sim.dev_path) as chip:
+                self.assertEqual(chip.get_line_info(5).consumer, "foobar")
+
+    def test_request_with_keyword_arguments(self):
+        with gpiod.request_lines(
+            path=self.sim.dev_path,
+            config={(0, 5, 6): None},
+            consumer="foobar",
+            event_buffer_size=16,
+        ) as req:
+            self.assertEqual(req.offsets, [0, 5, 6])
+            with gpiod.Chip(self.sim.dev_path) as chip:
+                self.assertEqual(chip.get_line_info(5).consumer, "foobar")
+
+    def test_request_single_offset_as_int(self):
+        with gpiod.request_lines(path=self.sim.dev_path, config={4: None}) as req:
+            self.assertEqual(req.offsets, [4])
+
+    def test_request_single_offset_as_tuple(self):
+        with gpiod.request_lines(path=self.sim.dev_path, config={(4): None}) as req:
+            self.assertEqual(req.offsets, [4])
+
+    def test_request_by_name(self):
+        with gpiod.request_lines(
+            self.sim.dev_path, {(1, 2, "foo", "bar"): None}
+        ) as req:
+            self.assertEqual(req.offsets, [1, 2, 5, 7])
+
+
+class LineRequestGettingValues(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=8)
+        self.req = gpiod.request_lines(
+            self.sim.dev_path,
+            {(0, 1, 2, 3): gpiod.LineSettings(direction=Direction.INPUT)},
+        )
+
+    def tearDown(self):
+        self.req.release()
+        del self.req
+        del self.sim
+
+    def test_get_single_value(self):
+        self.sim.set_pull(1, Pull.UP)
+
+        self.assertEqual(self.req.get_values([1]), [Value.ACTIVE])
+
+    def test_get_single_value_helper(self):
+        self.sim.set_pull(1, Pull.UP)
+
+        self.assertEqual(self.req.get_value(1), Value.ACTIVE)
+
+    def test_get_values_for_subset_of_lines(self):
+        self.sim.set_pull(0, Pull.UP)
+        self.sim.set_pull(1, Pull.DOWN)
+        self.sim.set_pull(3, Pull.UP)
+
+        self.assertEqual(
+            self.req.get_values([0, 1, 3]), [Value.ACTIVE, Value.INACTIVE, Value.ACTIVE]
+        )
+
+    def test_get_all_values(self):
+        self.sim.set_pull(0, Pull.DOWN)
+        self.sim.set_pull(1, Pull.UP)
+        self.sim.set_pull(2, Pull.UP)
+        self.sim.set_pull(3, Pull.UP)
+
+        self.assertEqual(
+            self.req.get_values(),
+            [Value.INACTIVE, Value.ACTIVE, Value.ACTIVE, Value.ACTIVE],
+        )
+
+    def test_get_values_invalid_offset(self):
+        with self.assertRaises(ValueError):
+            self.req.get_values([9])
+
+    def test_get_values_invalid_argument_type(self):
+        with self.assertRaises(TypeError):
+            self.req.get_values(True)
+
+
+class LineRequestGettingValuesByName(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=4, line_names={2: "foo", 3: "bar", 1: "baz"})
+        self.req = gpiod.request_lines(
+            self.sim.dev_path,
+            {(0, "baz", "bar", "foo"): gpiod.LineSettings(direction=Direction.INPUT)},
+        )
+
+    def tearDown(self):
+        self.req.release()
+        del self.req
+        del self.sim
+
+    def test_get_values_by_name(self):
+        self.sim.set_pull(1, Pull.UP)
+        self.sim.set_pull(2, Pull.DOWN)
+        self.sim.set_pull(3, Pull.UP)
+
+        self.assertEqual(
+            self.req.get_values(["foo", "bar", 1]),
+            [Value.INACTIVE, Value.ACTIVE, Value.ACTIVE],
+        )
+
+    def test_get_values_by_bad_name(self):
+        with self.assertRaises(ValueError):
+            self.req.get_values(["xyz"])
+
+
+class LineRequestSettingValues(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=8)
+        self.req = gpiod.request_lines(
+            self.sim.dev_path,
+            {(0, 1, 2, 3): gpiod.LineSettings(direction=Direction.OUTPUT)},
+        )
+
+    def tearDown(self):
+        self.req.release()
+        del self.req
+        del self.sim
+
+    def test_set_single_value(self):
+        self.req.set_values({1: Value.ACTIVE})
+        self.assertEqual(self.sim.get_value(1), SimVal.ACTIVE)
+
+    def test_set_single_value_helper(self):
+        self.req.set_value(1, Value.ACTIVE)
+        self.assertEqual(self.sim.get_value(1), SimVal.ACTIVE)
+
+    def test_set_values_for_subset_of_lines(self):
+        self.req.set_values({0: Value.ACTIVE, 1: Value.INACTIVE, 3: Value.ACTIVE})
+
+        self.assertEqual(self.sim.get_value(0), SimVal.ACTIVE)
+        self.assertEqual(self.sim.get_value(1), SimVal.INACTIVE)
+        self.assertEqual(self.sim.get_value(3), SimVal.ACTIVE)
+
+    def test_set_values_invalid_offset(self):
+        with self.assertRaises(ValueError):
+            self.req.set_values({9: Value.ACTIVE})
+
+
+class LineRequestSettingValuesByName(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=4, line_names={2: "foo", 3: "bar", 1: "baz"})
+        self.req = gpiod.request_lines(
+            self.sim.dev_path,
+            {(0, "baz", "bar", "foo"): gpiod.LineSettings(direction=Direction.OUTPUT)},
+        )
+
+    def tearDown(self):
+        self.req.release()
+        del self.req
+        del self.sim
+
+    def test_set_values_by_name(self):
+        self.req.set_values(
+            {"foo": Value.INACTIVE, "bar": Value.ACTIVE, 1: Value.ACTIVE}
+        )
+
+        self.assertEqual(self.sim.get_value(2), SimVal.INACTIVE)
+        self.assertEqual(self.sim.get_value(1), SimVal.ACTIVE)
+        self.assertEqual(self.sim.get_value(3), SimVal.ACTIVE)
+
+    def test_set_values_by_bad_name(self):
+        with self.assertRaises(ValueError):
+            self.req.set_values({"xyz": Value.ACTIVE})
+
+
+class LineRequestPropertiesWork(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=16, line_names={0: "foo", 2: "bar", 5: "baz"})
+
+    def tearDown(self):
+        del self.sim
+
+    def test_property_fd(self):
+        with gpiod.request_lines(
+            self.sim.dev_path,
+            config={
+                0: gpiod.LineSettings(
+                    direction=Direction.INPUT, edge_detection=Edge.BOTH
+                )
+            },
+        ) as req:
+            self.assertGreaterEqual(req.fd, 0)
+
+    def test_property_num_lines(self):
+        with gpiod.request_lines(
+            self.sim.dev_path, config={(0, 2, 3, 5, 6, 8, 12): None}
+        ) as req:
+            self.assertEqual(req.num_lines, 7)
+
+    def test_property_offsets(self):
+        with gpiod.request_lines(
+            self.sim.dev_path, config={(1, 6, 12, 4): None}
+        ) as req:
+            self.assertEqual(req.offsets, [1, 6, 12, 4])
+
+    def test_property_lines(self):
+        with gpiod.request_lines(
+            self.sim.dev_path, config={("foo", 1, "bar", 4, "baz"): None}
+        ) as req:
+            self.assertEqual(req.lines, ["foo", 1, "bar", 4, "baz"])
+
+
+class LineRequestConsumerString(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=4)
+        self.chip = gpiod.Chip(self.sim.dev_path)
+
+    def tearDown(self):
+        self.chip.close()
+        del self.chip
+        del self.sim
+
+    def test_custom_consumer(self):
+        with self.chip.request_lines(
+            consumer="foobar", config={(2, 3): None}
+        ) as request:
+            info = self.chip.get_line_info(2)
+            self.assertEqual(info.consumer, "foobar")
+
+    def test_empty_consumer(self):
+        with self.chip.request_lines(consumer="", config={(2, 3): None}) as request:
+            info = self.chip.get_line_info(2)
+            self.assertEqual(info.consumer, "?")
+
+    def test_default_consumer(self):
+        with self.chip.request_lines(config={(2, 3): None}) as request:
+            info = self.chip.get_line_info(2)
+            self.assertEqual(info.consumer, "?")
+
+
+class ReconfigureRequestedLines(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=8, line_names={3: "foo", 4: "bar", 6: "baz"})
+        self.chip = gpiod.Chip(self.sim.dev_path)
+        self.req = self.chip.request_lines(
+            {(0, 2, "foo", "baz"): gpiod.LineSettings(direction=Direction.OUTPUT)}
+        )
+
+    def tearDown(self):
+        self.chip.close()
+        del self.chip
+        self.req.release()
+        del self.req
+        del self.sim
+
+    def test_reconfigure_by_offsets(self):
+        info = self.chip.get_line_info(2)
+        self.assertEqual(info.direction, Direction.OUTPUT)
+        self.req.reconfigure_lines(
+            {(0, 2, 3, 6): gpiod.LineSettings(direction=Direction.INPUT)}
+        )
+        info = self.chip.get_line_info(2)
+        self.assertEqual(info.direction, Direction.INPUT)
+
+    def test_reconfigure_by_names(self):
+        info = self.chip.get_line_info(2)
+        self.assertEqual(info.direction, Direction.OUTPUT)
+        self.req.reconfigure_lines(
+            {(0, 2, "foo", "baz"): gpiod.LineSettings(direction=Direction.INPUT)}
+        )
+        info = self.chip.get_line_info(2)
+        self.assertEqual(info.direction, Direction.INPUT)
+
+
+class ReleasedLineRequestCannotBeUsed(TestCase):
+    def test_using_released_line_request(self):
+        sim = gpiosim.Chip()
+
+        with gpiod.Chip(sim.dev_path) as chip:
+            req = chip.request_lines(config={0: None})
+            req.release()
+
+            with self.assertRaises(gpiod.RequestReleasedError):
+                req.fd
+
+
+class LineRequestSurvivesParentChip(TestCase):
+    def test_line_request_survives_parent_chip(self):
+        sim = gpiosim.Chip()
+
+        chip = gpiod.Chip(sim.dev_path)
+        try:
+            req = chip.request_lines(
+                config={0: gpiod.LineSettings(direction=Direction.INPUT)}
+            )
+        except:
+            chip.close()
+            raise
+
+        chip.close()
+        self.assertEqual(req.get_values([0]), [Value.INACTIVE])
+
+
+class LineRequestStringRepresentation(TestCase):
+    def setUp(self):
+        self.sim = gpiosim.Chip(num_lines=8)
+
+    def tearDown(self):
+        del self.sim
+
+    def test_str(self):
+        with gpiod.request_lines(self.sim.dev_path, config={(2, 6, 4, 1): None}) as req:
+            self.assertEqual(
+                str(req),
+                "<LineRequest num_lines=4 offsets=[2, 6, 4, 1] fd={}>".format(req.fd),
+            )
+
+    def test_str_released(self):
+        req = gpiod.request_lines(self.sim.dev_path, config={(2, 6, 4, 1): None})
+        req.release()
+        self.assertEqual(str(req), "<LineRequest RELEASED>")
diff --git a/bindings/python/tests/tests_line_settings.py b/bindings/python/tests/tests_line_settings.py
new file mode 100644
index 0000000..36dda6d
--- /dev/null
+++ b/bindings/python/tests/tests_line_settings.py
@@ -0,0 +1,79 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
+
+import gpiod
+
+from . import gpiosim
+from datetime import timedelta
+from gpiod.line import Direction, Edge, Bias, Drive, Value, Clock
+from unittest import TestCase
+
+
+class LineSettingsConstructor(TestCase):
+    def test_default_values(self):
+        settings = gpiod.LineSettings()
+
+        self.assertEqual(settings.direction, Direction.AS_IS)
+        self.assertEqual(settings.edge_detection, Edge.NONE)
+        self.assertEqual(settings.bias, Bias.AS_IS)
+        self.assertEqual(settings.drive, Drive.PUSH_PULL)
+        self.assertFalse(settings.active_low)
+        self.assertEqual(settings.debounce_period.total_seconds(), 0.0)
+        self.assertEqual(settings.event_clock, Clock.MONOTONIC)
+        self.assertEqual(settings.output_value, Value.INACTIVE)
+
+    def test_keyword_arguments(self):
+        settings = gpiod.LineSettings(
+            direction=Direction.INPUT,
+            edge_detection=Edge.BOTH,
+            bias=Bias.PULL_UP,
+            event_clock=Clock.REALTIME,
+        )
+
+        self.assertEqual(settings.direction, Direction.INPUT)
+        self.assertEqual(settings.edge_detection, Edge.BOTH)
+        self.assertEqual(settings.bias, Bias.PULL_UP)
+        self.assertEqual(settings.drive, Drive.PUSH_PULL)
+        self.assertFalse(settings.active_low)
+        self.assertEqual(settings.debounce_period.total_seconds(), 0.0)
+        self.assertEqual(settings.event_clock, Clock.REALTIME)
+        self.assertEqual(settings.output_value, Value.INACTIVE)
+
+
+class LineSettingsAttributes(TestCase):
+    def test_line_settings_attributes_are_mutable(self):
+        settings = gpiod.LineSettings()
+
+        settings.direction = Direction.INPUT
+        settings.edge_detection = Edge.BOTH
+        settings.bias = Bias.DISABLED
+        settings.debounce_period = timedelta(microseconds=3000)
+        settings.event_clock = Clock.HTE
+
+        self.assertEqual(settings.direction, Direction.INPUT)
+        self.assertEqual(settings.edge_detection, Edge.BOTH)
+        self.assertEqual(settings.bias, Bias.DISABLED)
+        self.assertEqual(settings.drive, Drive.PUSH_PULL)
+        self.assertFalse(settings.active_low)
+        self.assertEqual(settings.debounce_period.total_seconds(), 0.003)
+        self.assertEqual(settings.event_clock, Clock.HTE)
+        self.assertEqual(settings.output_value, Value.INACTIVE)
+
+
+class LineSettingsStringRepresentation(TestCase):
+    def setUp(self):
+        self.settings = gpiod.LineSettings(
+            direction=Direction.OUTPUT, drive=Drive.OPEN_SOURCE, active_low=True
+        )
+
+    def test_repr(self):
+        self.assertEqual(
+            repr(self.settings),
+            "LineSettings(direction=Direction.OUTPUT, edge_detection=Edge.NONE bias=Bias.AS_IS drive=Drive.OPEN_SOURCE active_low=True debounce_period=datetime.timedelta(0) event_clock=Clock.MONOTONIC output_value=Value.INACTIVE)",
+        )
+
+    def test_str(self):
+        self.assertEqual(
+            str(self.settings),
+            "<LineSettings direction=Direction.OUTPUT edge_detection=Edge.NONE bias=Bias.AS_IS drive=Drive.OPEN_SOURCE active_low=True debounce_period=0:00:00 event_clock=Clock.MONOTONIC output_value=Value.INACTIVE>",
+        )
diff --git a/bindings/python/tests/tests_module.py b/bindings/python/tests/tests_module.py
new file mode 100644
index 0000000..4eeae76
--- /dev/null
+++ b/bindings/python/tests/tests_module.py
@@ -0,0 +1,59 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+# SPDX-FileCopyrightText: 2022 Bartosz Golaszewski <brgl@bgdev.pl>
+
+import gpiod
+import os
+import re
+import unittest
+
+from . import gpiosim
+from .helpers import LinkGuard
+from unittest import TestCase
+
+
+class IsGPIOChip(TestCase):
+    def test_is_gpiochip_bad(self):
+        self.assertFalse(gpiod.is_gpiochip_device("/dev/null"))
+        self.assertFalse(gpiod.is_gpiochip_device("/dev/nonexistent"))
+
+    def test_is_gpiochip_invalid_argument(self):
+        with self.assertRaises(TypeError):
+            gpiod.is_gpiochip_device(4)
+
+    def test_is_gpiochip_superfluous_argument(self):
+        with self.assertRaises(TypeError):
+            gpiod.is_gpiochip_device("/dev/null", 4)
+
+    def test_is_gpiochip_missing_argument(self):
+        with self.assertRaises(TypeError):
+            gpiod.is_gpiochip_device()
+
+    def test_is_gpiochip_good(self):
+        sim = gpiosim.Chip()
+        self.assertTrue(gpiod.is_gpiochip_device(sim.dev_path))
+
+    def test_is_gpiochip_good_keyword_argument(self):
+        sim = gpiosim.Chip()
+        self.assertTrue(gpiod.is_gpiochip_device(path=sim.dev_path))
+
+    def test_is_gpiochip_link_good(self):
+        link = "/tmp/gpiod-py-test-link.{}".format(os.getpid())
+        sim = gpiosim.Chip()
+
+        with LinkGuard(sim.dev_path, link):
+            self.assertTrue(gpiod.is_gpiochip_device(link))
+
+    def test_is_gpiochip_link_bad(self):
+        link = "/tmp/gpiod-py-test-link.{}".format(os.getpid())
+
+        with LinkGuard("/dev/null", link):
+            self.assertFalse(gpiod.is_gpiochip_device(link))
+
+
+class VersionString(TestCase):
+    def test_version_string(self):
+        self.assertTrue(
+            re.match(
+                "^[0-9][1-9]?\\.[0-9][1-9]?([\\.0-9]?|\\-devel)$", gpiod.__version__
+            )
+        )
