diff --git a/mercurial/testing/revlog.py b/mercurial/testing/revlog.py
index d55f2f7d278562028d250ccdd9ba7ce0bb46a0c4_bWVyY3VyaWFsL3Rlc3RpbmcvcmV2bG9nLnB5..674e1e7be85ae60a575a94fe643c92684c7abb37_bWVyY3VyaWFsL3Rlc3RpbmcvcmV2bG9nLnB5 100644
--- a/mercurial/testing/revlog.py
+++ b/mercurial/testing/revlog.py
@@ -44,5 +44,15 @@
     rust_revlog = None
 
 
+try:
+    from ..pyo3_rustext import (  # pytype: disable=import-error
+        revlog as pyo3_revlog,
+    )
+
+    pyo3_revlog.__name__  # force actual import
+except ImportError:
+    pyo3_revlog = None
+
+
 @unittest.skipIf(
     cparsers is None,
@@ -47,6 +57,9 @@
 @unittest.skipIf(
     cparsers is None,
-    'The C version of the "parsers" module is not available. It is needed for this test.',
+    (
+        'The C version of the "parsers" module is not available. '
+        'It is needed for this test.'
+    ),
 )
 class RevlogBasedTestBase(unittest.TestCase):
     def parseindex(self, data=None):
@@ -65,9 +78,17 @@
     revlog_delta_config = revlog.DeltaConfig()
     revlog_feature_config = revlog.FeatureConfig()
 
+    @classmethod
+    def irl_class(cls):
+        return rust_revlog.InnerRevlog
+
+    @classmethod
+    def nodetree(cls, idx):
+        return rust_revlog.NodeTree(idx)
+
     def make_inner_revlog(
         self, data=None, vfs_is_readonly=True, kind=KIND_CHANGELOG
     ):
         if data is None:
             data = data_non_inlined
 
@@ -68,10 +89,10 @@
     def make_inner_revlog(
         self, data=None, vfs_is_readonly=True, kind=KIND_CHANGELOG
     ):
         if data is None:
             data = data_non_inlined
 
-        return rust_revlog.InnerRevlog(
+        return self.irl_class()(
             vfs_base=b"Just a path",
             fncache=None,  # might be enough for now
             vfs_is_readonly=vfs_is_readonly,
@@ -91,3 +112,17 @@
 
     def parserustindex(self, data=None):
         return revlog.RustIndexProxy(self.make_inner_revlog(data=data))
+
+
+@unittest.skipIf(
+    pyo3_revlog is None,
+    'The Rust PyO3 revlog module is not available. It is needed for this test.',
+)
+class PyO3RevlogBasedTestBase(RustRevlogBasedTestBase):
+    @classmethod
+    def irl_class(cls):
+        return pyo3_revlog.InnerRevlog
+
+    @classmethod
+    def nodetree(cls, idx):
+        return pyo3_revlog.NodeTree(idx)
diff --git a/tests/test-rust-revlog.py b/tests/test-rust-revlog.py
new file mode 100644
index 0000000000000000000000000000000000000000..674e1e7be85ae60a575a94fe643c92684c7abb37_dGVzdHMvdGVzdC1ydXN0LXJldmxvZy5weQ==
--- /dev/null
+++ b/tests/test-rust-revlog.py
@@ -0,0 +1,97 @@
+import struct
+
+from mercurial.node import (
+    bin as node_bin,
+    hex,
+)
+
+try:
+    from mercurial import rustext
+
+    rustext.__name__  # trigger immediate actual import
+except ImportError:
+    rustext = None
+else:
+    # this would fail already without appropriate ancestor.__package__
+    from mercurial.rustext.ancestor import LazyAncestors
+
+from mercurial.testing import revlog as revlogtesting
+
+header = struct.unpack(">I", revlogtesting.data_non_inlined[:4])[0]
+
+
+class RustInnerRevlogTestMixin:
+    """Common tests for both Rust Python bindings."""
+
+    node_hex0 = b'd1f4bbb0befc13bd8cd39d0fcdd93b8c078c4a2f'
+    node0 = node_bin(node_hex0)
+
+
+# Conditional skipping done by the base class
+class RustInnerRevlogTest(
+    revlogtesting.RustRevlogBasedTestBase, RustInnerRevlogTestMixin
+):
+    """For reference"""
+
+    def test_heads(self):
+        idx = self.parserustindex()
+        self.assertEqual(idx.headrevs(), [3])
+
+    def test_len(self):
+        idx = self.parserustindex()
+        self.assertEqual(len(idx), 4)
+
+    def test_ancestors(self):
+        rustidx = self.parserustindex()
+        lazy = LazyAncestors(rustidx, [3], 0, True)
+        # we have two more references to the index:
+        # - in its inner iterator for __contains__ and __bool__
+        # - in the LazyAncestors instance itself (to spawn new iterators)
+        self.assertTrue(2 in lazy)
+        self.assertTrue(bool(lazy))
+        self.assertEqual(list(lazy), [3, 2, 1, 0])
+        # a second time to validate that we spawn new iterators
+        self.assertEqual(list(lazy), [3, 2, 1, 0])
+
+        # let's check bool for an empty one
+        self.assertFalse(LazyAncestors(rustidx, [0], 0, False))
+
+    def test_standalone_nodetree(self):
+        idx = self.parserustindex()
+        nt = self.nodetree(idx)
+        for i in range(4):
+            nt.insert(i)
+
+        bin_nodes = [entry[7] for entry in idx]
+        hex_nodes = [hex(n) for n in bin_nodes]
+
+        for i, node in enumerate(hex_nodes):
+            self.assertEqual(nt.prefix_rev_lookup(node), i)
+            self.assertEqual(nt.prefix_rev_lookup(node[:5]), i)
+
+        # all 4 revisions in idx (standard data set) have different
+        # first nybbles in their Node IDs,
+        # hence `nt.shortest()` should return 1 for them, except when
+        # the leading nybble is 0 (ambiguity with NULL_NODE)
+        for i, (bin_node, hex_node) in enumerate(zip(bin_nodes, hex_nodes)):
+            shortest = nt.shortest(bin_node)
+            expected = 2 if hex_node[0] == ord('0') else 1
+            self.assertEqual(shortest, expected)
+            self.assertEqual(nt.prefix_rev_lookup(hex_node[:shortest]), i)
+
+        # test invalidation (generation poisoning) detection
+        del idx[3]
+        self.assertTrue(nt.is_invalidated())
+
+
+# Conditional skipping done by the base class
+class PyO3InnerRevlogTest(
+    revlogtesting.PyO3RevlogBasedTestBase, RustInnerRevlogTestMixin
+):
+    """Testing new PyO3 bindings, by comparison with rust-cpython bindings."""
+
+
+if __name__ == '__main__':
+    import silenttestrunner
+
+    silenttestrunner.main(__name__)