diff --git a/tests-extra/tests/catalog/basic/test.py b/tests-extra/tests/catalog/basic/test.py
index 13b228850c59c29e610e27c1f5b148a1edd8a1ba..581fee9ee72073792a691ed6fd1a39c718a99726 100644
--- a/tests-extra/tests/catalog/basic/test.py
+++ b/tests-extra/tests/catalog/basic/test.py
@@ -31,8 +31,8 @@ zone = t.zone("example.com.") + t.zone("catalog1.", storage=".")
 
 t.link(zone, master, slave, ixfr=True)
 
-master.zones["catalog1."].catalog = True
-slave.zones["catalog1."].catalog = True
+master.cat_interpret(zone[1])
+slave.cat_interpret(zone[1])
 
 if random.choice([True, False]):
     slave.dnssec(zone[1]).enable = True
diff --git a/tests-extra/tests/catalog/gen_groups/test.py b/tests-extra/tests/catalog/gen_groups/test.py
index f39874b057a24d4bc17a286cef18b1883e181d55..6853774c99642cdf30e99520ff800d353b610010 100644
--- a/tests-extra/tests/catalog/gen_groups/test.py
+++ b/tests-extra/tests/catalog/gen_groups/test.py
@@ -30,15 +30,14 @@ catz = t.zone("example.")
 zone = t.zone_rnd(2, dnssec=False)
 
 t.link(catz, master, slave)
-t.link(zone, master)
+t.link(zone, master, slave)
 
-for z in zone:
-    master.zones[z.name].catalog_gen_link(master.zones[catz[0].name])
-
-master.zones[zone[0].name].catalog_group = "catalog-signed"
-master.zones[zone[1].name].catalog_group = "catalog-unsigned"
-
-slave.zones[catz[0].name].catalog = True
+master.cat_generate(catz)
+slave.cat_interpret(catz)
+master.cat_member(zone[0], catz, "catalog-signed")
+slave.cat_hidden(zone[0])
+master.cat_member(zone[1], catz, "catalog-unsigned")
+slave.cat_hidden(zone[1])
 
 t.start()
 
@@ -53,7 +52,7 @@ resp = slave.dig(zone[1].name, "SOA", dnssec=True)
 resp.check(rcode="NOERROR")
 resp.check_count(0, "RRSIG")
 
-master.zones[zone[1].name].catalog_group = "catalog-signed"
+master.cat_member(zone[1], catz, "catalog-signed")
 master.gen_confile()
 master.reload()
 
diff --git a/tests-extra/tests/catalog/generate/test.py b/tests-extra/tests/catalog/generate/test.py
index 9defa61ae5730bf98873cfcd17fc0be1b041798f..3567acc5e1e0644ef74e7a41fe44f8f916904a85 100644
--- a/tests-extra/tests/catalog/generate/test.py
+++ b/tests-extra/tests/catalog/generate/test.py
@@ -30,12 +30,13 @@ catz = t.zone("example.")
 zone = t.zone("example.com.")
 
 t.link(catz, master, slave)
-t.link(zone, master)
+t.link(zone, master, slave)
 
-for z in zone:
-    master.zones[z.name].catalog_gen_link(master.zones[catz[0].name])
+master.cat_generate(catz)
+slave.cat_interpret(catz)
+master.cat_member(zone, catz)
+slave.cat_hidden(zone)
 
-slave.zones[catz[0].name].catalog = True
 slave.dnssec(catz[0]).enable = True
 slave.dnssec(catz[0]).single_type_signing = True
 
@@ -48,9 +49,10 @@ slave.zones_wait(zone)
 add_online = random.choice([True, False])
 
 zone_add = t.zone("flags.") + t.zone("records.")
-t.link(zone_add, master)
+t.link(zone_add, master, slave)
 for z in zone_add:
-    master.zones[z.name].catalog_gen_link(master.zones[catz[0].name])
+    master.cat_member(z, catz)
+    slave.cat_hidden(z)
 
 master.gen_confile()
 
diff --git a/tests-extra/tests/catalog/groups/test.py b/tests-extra/tests/catalog/groups/test.py
index ec7f3b9b5b61d5d616da2f467d53aefeb26f3cc0..c31847664374ef8b80f88c4b8dffa6e9c89b5203 100644
--- a/tests-extra/tests/catalog/groups/test.py
+++ b/tests-extra/tests/catalog/groups/test.py
@@ -30,7 +30,7 @@ zone = t.zone("catalog2.", storage=".")
 
 t.link(zone, master)
 
-master.zones["catalog2."].catalog = True
+master.cat_interpret(zone)
 
 for zf in glob.glob(t.data_dir + "/*.zone"):
     shutil.copy(zf, master.dir + "/master")
diff --git a/tests-extra/tests/catalog/kill/test.py b/tests-extra/tests/catalog/kill/test.py
index 424c57b052d76ca7ed5668de434b867b5f724f48..3371ade9249461442a6478208aeddc7e23873f1d 100644
--- a/tests-extra/tests/catalog/kill/test.py
+++ b/tests-extra/tests/catalog/kill/test.py
@@ -15,7 +15,8 @@ stuckzone = t.zone("records.")
 
 t.link(catz + stuckzone, master)
 
-master.zones[catz[0].name].catalog = True
+master.cat_interpret(catz)
+
 master.zones[catz[0].name].journal_content = "all"
 
 master.dnssec(stuckzone).enable = True
diff --git a/tests-extra/tests/catalog/many_zones/test.py b/tests-extra/tests/catalog/many_zones/test.py
index 21b60b6e5f28264abda6fccfed6e519b46390343..f7c8e0d49d122d5cc51a8b891f66a9d729dac9b1 100644
--- a/tests-extra/tests/catalog/many_zones/test.py
+++ b/tests-extra/tests/catalog/many_zones/test.py
@@ -23,9 +23,10 @@ catz = t.zone("example.")
 t.link(catz, master, slave)
 
 cz = master.zones[catz[0].name]
-cz.catalog_gen_link(cz) # empty catz with "generate" role
 
-slave.zones[catz[0].name].catalog = True
+master.cat_generate(cz)
+slave.cat_interpret(cz)
+
 slave.dnssec(catz[0]).enable = DNSSEC
 slave.dnssec(catz[0]).alg = "ECDSAP256SHA256"
 slave.zones[catz[0].name].journal_content = "all"
@@ -40,9 +41,10 @@ slave.zone_wait(catz, udp=False, tsig=True)
 
 for i in range(UPDATES):
     zone_add = t.zone_rnd(ADD_ZONES, records=5, dnssec=False)
-    t.link(zone_add, master)
+    t.link(zone_add, master, slave)
     for z in zone_add:
-        master.zones[z.name].catalog_gen_link(master.zones[catz[0].name])
+        master.cat_member(z, catz)
+        slave.cat_hidden(z)
     master.gen_confile()
     master.reload()
     slave.zones_wait(zone_add)
diff --git a/tests-extra/tests/catalog/multi/test.py b/tests-extra/tests/catalog/multi/test.py
index 8fae1793ccd6cf8d516b258f982e80dbc56a276f..0858fe6d837dfe070cfd45e7de2c90194ff8ae5b 100644
--- a/tests-extra/tests/catalog/multi/test.py
+++ b/tests-extra/tests/catalog/multi/test.py
@@ -43,9 +43,9 @@ zone = t.zone("example.com.") + t.zone("catalog1.", storage=".") + t.zone("catal
 
 t.link(zone, master)
 
-master.zones["catalog1."].catalog = True
-master.zones["catalog2."].catalog = True
-master.zones["catalog3."].catalog = True
+master.cat_interpret(zone[1])
+master.cat_interpret(zone[2])
+master.cat_interpret(zone[3])
 
 t.start()
 t.sleep(5)
diff --git a/tests-extra/tests/catalog/rapid_updates/test.py b/tests-extra/tests/catalog/rapid_updates/test.py
index 2cf23c08f80492ca32ae8d740e599a71af9d21cc..35c6455be62a94e679c4b8b771bfa1ebf73495e4 100644
--- a/tests-extra/tests/catalog/rapid_updates/test.py
+++ b/tests-extra/tests/catalog/rapid_updates/test.py
@@ -21,7 +21,7 @@ knot = t.server("knot")
 catz = t.zone("catalog1.", storage=".")
 
 t.link(catz, knot)
-knot.zones[catz[0].name].catalog = True
+knot.cat_interpret(catz)
 
 t.start()
 
diff --git a/tests-extra/tools/dnstest/server.py b/tests-extra/tools/dnstest/server.py
index 4152119ef2261a6e821b284f598c08896c6c5af2..342aa06157c218b254f177f5ffa8eb527e66935e 100644
--- a/tests-extra/tools/dnstest/server.py
+++ b/tests-extra/tools/dnstest/server.py
@@ -1,6 +1,7 @@
 #!/usr/bin/env python3
 
 import base64
+import enum
 import glob
 import inspect
 import ipaddress
@@ -73,6 +74,15 @@ class ZoneDnssec(object):
         self.dnskey_mgmt = None
         self.offline_ksk = None
 
+class ZoneCatalogRole(enum.IntEnum):
+    """Zone catalog roles."""
+
+    NONE = 0
+    INTERPRET = 1
+    GENERATE = 2
+    MEMBER = 3
+    HIDDEN = 4 # Interpreted member zone
+
 class Zone(object):
     '''DNS zone description'''
 
@@ -85,8 +95,8 @@ class Zone(object):
         self.journal_content = journal_content # journal contents
         self.modules = []
         self.dnssec = ZoneDnssec()
-        self.catalog = None
-        self.catalog_zone = None
+        self.catalog_role = ZoneCatalogRole.NONE
+        self.catalog_gen_name = None # Generated catalog name for this member
         self.catalog_group = None
         self.refresh_max = None
         self.refresh_min = None
@@ -110,10 +120,6 @@ class Zone(object):
     def clear_modules(self):
         self.modules.clear()
 
-    def catalog_gen_link(self, catalog_zone):
-        self.catalog_zone = catalog_zone
-        catalog_zone.catalog_zone = catalog_zone
-
     def disable_master(self, new_zone_file):
         self.zfile.remove()
         self.zfile = new_zone_file
@@ -251,6 +257,25 @@ class Server(object):
 
         z.masters.add(master)
 
+    def cat_interpret(self, zone):
+        z = self.zones[zone_arg_check(zone).name]
+        z.catalog_role = ZoneCatalogRole.INTERPRET
+
+    def cat_generate(self, zone):
+        z = self.zones[zone_arg_check(zone).name]
+        z.catalog_role = ZoneCatalogRole.GENERATE
+
+    def cat_member(self, zone, catalog, group=None):
+        z = self.zones[zone_arg_check(zone).name]
+        c = self.zones[zone_arg_check(catalog).name]
+        z.catalog_role = ZoneCatalogRole.MEMBER
+        z.catalog_gen_name = c.name
+        z.catalog_group = group
+
+    def cat_hidden(self, zone):
+        z = self.zones[zone_arg_check(zone).name]
+        z.catalog_role = ZoneCatalogRole.HIDDEN
+
     def compile(self):
         try:
             p = Popen([self.control_bin] + self.compile_params,
@@ -1470,8 +1495,9 @@ class Knot(Server):
         have_catalog = None
         for zone in self.zones:
             z = self.zones[zone]
-            if z.catalog:
+            if z.catalog_role in [ZoneCatalogRole.INTERPRET, ZoneCatalogRole.GENERATE]:
                 have_catalog = z
+                break
         if have_catalog is not None:
             s.id_item("id", "catalog-default")
             s.item_str("file", self.dir + "/master/%s.zone")
@@ -1504,6 +1530,9 @@ class Knot(Server):
         s.begin("zone")
         for zone in sorted(self.zones):
             z = self.zones[zone]
+            if z.catalog_role == ZoneCatalogRole.HIDDEN:
+                continue
+
             s.id_item("domain", z.name)
             s.item_str("file", z.zfile.path)
 
@@ -1525,22 +1554,20 @@ class Knot(Server):
             elif z.ixfr:
                 s.item_str("zonefile-load", "difference")
 
-            if z.catalog_zone == z:
+            if z.catalog_role == ZoneCatalogRole.GENERATE:
                 s.item_str("catalog-role", "generate")
-            elif z.catalog_zone is not None:
+            elif z.catalog_role == ZoneCatalogRole.MEMBER:
                 s.item_str("catalog-role", "member")
-                s.item_str("catalog-zone", z.catalog_zone.name)
+                s.item_str("catalog-zone", z.catalog_gen_name)
+                self._str(s, "catalog-group", z.catalog_group)
+            elif z.catalog_role == ZoneCatalogRole.INTERPRET:
+                s.item_str("catalog-role", "interpret")
+                s.item("catalog-template", "[ catalog-default, catalog-signed, catalog-unsigned ]")
 
             if z.dnssec.enable:
                 s.item_str("dnssec-signing", "off" if z.dnssec.disable else "on")
                 s.item_str("dnssec-policy", z.dnssec.shared_policy_with or z.name)
 
-            if z.catalog:
-                s.item_str("catalog-role", "interpret")
-                s.item("catalog-template", "[ catalog-default, catalog-signed, catalog-unsigned ]")
-
-            self._str(s, "catalog-group", z.catalog_group)
-
             self._bool(s, "dnssec-validation", z.dnssec.validate)
 
             if len(z.modules) > 0: