diff --git a/tests-extra/tests/modules/stats/test.py b/tests-extra/tests/modules/stats/test.py
new file mode 100644
index 0000000000000000000000000000000000000000..23c0e6f987543094fdd94cad7ed109d330b07ff1
--- /dev/null
+++ b/tests-extra/tests/modules/stats/test.py
@@ -0,0 +1,165 @@
+#!/usr/bin/env python3
+
+''' Check 'stats' query module functionality. '''
+
+import os
+import random
+
+from dnstest.libknot import libknot
+from dnstest.module import ModStats
+from dnstest.test import Test
+from dnstest.utils import *
+
+def check_item(server, section, item, value, idx=None, zone=None):
+    try:
+        ctl = libknot.control.KnotCtl()
+        ctl.connect(os.path.join(server.dir, "knot.sock"))
+
+        if zone:
+            ctl.send_block(cmd="zone-stats", section=section, item=item, zone=zone.name)
+        else:
+            ctl.send_block(cmd="stats", section=section, item=item)
+
+        stats = ctl.receive_stats()
+    finally:
+        ctl.send(libknot.control.KnotCtlType.END)
+        ctl.close()
+
+    if not stats and value == -1:
+        return
+
+    if zone:
+        stats = stats.get("zone").get(zone.name.lower())
+
+    if idx:
+        if value == -1:
+            isset(idx not in stats.get(section).get(item), idx)
+            return
+        else:
+            data = int(stats.get(section).get(item).get(idx))
+    else:
+        data = int(stats.get(section).get(item))
+
+    compare(data, value, "%s.%s" % (section, item))
+
+ModStats.check()
+
+proto = random.choice([4, 6])
+
+t = Test(stress=False, tsig=False, address=proto)
+
+knot = t.server("knot")
+zones = t.zone_rnd(2)
+
+t.link(zones, knot)
+
+knot.add_module(None,     ModStats())
+knot.add_module(zones[0], ModStats())
+knot.add_module(zones[1], ModStats())
+
+t.start()
+t.sleep(1)
+
+check_item(knot, "server", "zone-count", 2)
+
+resp = knot.dig(zones[0].name, "SOA", tries=1, udp=True)
+query_size1 = resp.query_size()
+reply_size1 = resp.response_size()
+
+resp = knot.dig(zones[0].name, "NS", tries=1, udp=False)
+query_size2 = resp.query_size()
+reply_size2 = resp.response_size()
+
+resp = knot.dig(zones[1].name, "TYPE11", tries=1, udp=True)
+query_size3 = resp.query_size()
+reply_size3 = resp.response_size()
+
+# Sucessfull transfer.
+resp = knot.dig(zones[0].name, "AXFR", tries=1)
+resp.check_xfr(rcode="NOERROR")
+xfr_query_size = resp.query_size()
+# Cannot get xfr_reply_size :-/
+
+# Successfull update.
+up = knot.update(zones[1])
+up.add(zones[1].name, "3600", "AAAA", "::1")
+up.send("NOERROR")
+ddns_query_size = up.query_size()
+# Due to DDNS bulk processing, failed RCODE and response-bytes are not incremented!
+
+# Check request protocol metrics.
+check_item(knot, "mod-stats", "request-protocol", 2, "udp%s" % proto)
+check_item(knot, "mod-stats", "request-protocol", 1, "udp%s" % proto, zone=zones[0])
+check_item(knot, "mod-stats", "request-protocol", 1, "udp%s" % proto, zone=zones[1])
+
+check_item(knot, "mod-stats", "request-protocol", 3, "tcp%s" % proto)
+check_item(knot, "mod-stats", "request-protocol", 2, "tcp%s" % proto, zone=zones[0])
+
+# Check request/response bytes metrics.
+check_item(knot, "mod-stats", "request-bytes",  query_size1 + query_size2 + query_size3,
+                                                "query")
+check_item(knot, "mod-stats", "request-bytes",  ddns_query_size, "update")
+check_item(knot, "mod-stats", "request-bytes",  xfr_query_size, "other")
+
+check_item(knot, "mod-stats", "response-bytes", reply_size1 + reply_size2 + reply_size3,
+                                                "reply")
+
+check_item(knot, "mod-stats", "request-bytes",  query_size1 + query_size2, "query",
+                                                zone=zones[0])
+check_item(knot, "mod-stats", "response-bytes", reply_size1 + reply_size2, "reply",
+                                                zone=zones[0])
+
+check_item(knot, "mod-stats", "request-bytes",  query_size3, "query", zone=zones[1])
+check_item(knot, "mod-stats", "response-bytes", reply_size3, "reply", zone=zones[1])
+
+# Check query size metrics (just for global module).
+indices = dict()
+for size in [query_size1, query_size2, query_size3]:
+    idx = "%i-%i" % (int(size / 16) * 16, int(size / 16) * 16 + 15)
+    if idx not in indices:
+        indices[idx] = 1
+    else:
+        indices[idx] += 1;
+for size in indices:
+    check_item(knot, "mod-stats", "query-size", indices[size], idx=size)
+
+# Check reply size metrics (just for global module).
+indices = dict()
+for size in [reply_size1, reply_size2, reply_size3]:
+    idx = "%i-%i" % (int(size / 16) * 16, int(size / 16) * 16 + 15)
+    if idx not in indices:
+        indices[idx] = 1
+    else:
+        indices[idx] += 1;
+for size in indices:
+    check_item(knot, "mod-stats", "reply-size", indices[size], idx=size)
+
+# Check query type metrics.
+check_item(knot, "mod-stats", "query-type",  1, idx="SOA")
+check_item(knot, "mod-stats", "query-type",  1, idx="NS")
+check_item(knot, "mod-stats", "query-type",  1, idx="TYPE11")
+
+check_item(knot, "mod-stats", "query-type",  1, idx="SOA",    zone=zones[0])
+check_item(knot, "mod-stats", "query-type",  1, idx="NS",     zone=zones[0])
+check_item(knot, "mod-stats", "query-type", -1, idx="TYPE11", zone=zones[0])
+
+check_item(knot, "mod-stats", "query-type", -1, idx="SOA",    zone=zones[1])
+check_item(knot, "mod-stats", "query-type", -1, idx="NS",     zone=zones[1])
+check_item(knot, "mod-stats", "query-type",  1, idx="TYPE11", zone=zones[1])
+
+# Check server operation metrics.
+check_item(knot, "mod-stats", "server-operation", 3, idx="query")
+check_item(knot, "mod-stats", "server-operation", 1, idx="axfr")
+check_item(knot, "mod-stats", "server-operation", 1, idx="update")
+
+# Check response code metrics.
+check_item(knot, "mod-stats", "response-code", 4, idx="NOERROR")
+check_item(knot, "mod-stats", "response-code", 3, idx="NOERROR", zone=zones[0])
+check_item(knot, "mod-stats", "response-code", 1, idx="NOERROR", zone=zones[1])
+
+# Check nodata metrics.
+check_item(knot, "mod-stats", "reply-nodata",  1, idx="other")
+check_item(knot, "mod-stats", "reply-nodata", -1, idx="other", zone=zones[0])
+check_item(knot, "mod-stats", "reply-nodata",  1, idx="other", zone=zones[1])
+
+t.end()
diff --git a/tests-extra/tools/dnstest/module.py b/tests-extra/tools/dnstest/module.py
index df40d466a7328edafe806b10be2f46793b542c46..744ed25f1fbf1b5052d9cbff251fedc37347fd42 100644
--- a/tests-extra/tools/dnstest/module.py
+++ b/tests-extra/tools/dnstest/module.py
@@ -181,3 +181,36 @@ class ModRosedb(KnotModule):
             set_err("ROSEDB_TOOL")
             detail_log("!Failed to add a record into rosedb '%s'" % self.dbdir)
             detail_log(SEP)
+
+class ModStats(KnotModule):
+    '''Stats module'''
+
+    src_name = "stats_load"
+    conf_name = "mod-stats"
+
+    def __init__(self):
+        super().__init__()
+
+    def _bool(self, conf, name, value=True):
+        conf.item_str(name, "on" if value else "off")
+
+    def get_conf(self, conf=None):
+        if not conf:
+            conf = dnstest.config.KnotConf()
+
+        conf.begin(self.conf_name)
+        conf.id_item("id", self.conf_id)
+        self._bool(conf, "request-protocol", True)
+        self._bool(conf, "server-operation", True)
+        self._bool(conf, "request-bytes", True)
+        self._bool(conf, "response-bytes", True)
+        self._bool(conf, "edns-presence", True)
+        self._bool(conf, "flag-presence", True)
+        self._bool(conf, "response-code", True)
+        self._bool(conf, "reply-nodata", True)
+        self._bool(conf, "query-type", True)
+        self._bool(conf, "query-size", True)
+        self._bool(conf, "reply-size", True)
+        conf.end()
+
+        return conf
diff --git a/tests-extra/tools/dnstest/response.py b/tests-extra/tools/dnstest/response.py
index 2df0effb14de0591f889eb2c12f49a252939a35c..f5ae00d3bba91215be137444173553f7b5c49ba7 100644
--- a/tests-extra/tools/dnstest/response.py
+++ b/tests-extra/tools/dnstest/response.py
@@ -392,3 +392,13 @@ class Response(object):
                 for rr in nsec3_rrs:
                     detail_log("  %s" % rr)
                 detail_log(SEP)
+
+    def query_size(self):
+        '''Return query size.'''
+
+        return len(self.query.to_wire())
+
+    def response_size(self):
+        '''Return response size.'''
+
+        return len(self.resp.to_wire())
diff --git a/tests-extra/tools/dnstest/update.py b/tests-extra/tools/dnstest/update.py
index 6532be807dcad4b53f753831ad30757ee9ac2c79..6c9ef5412e304fbb901a3253fbfa245e2c783ee8 100644
--- a/tests-extra/tools/dnstest/update.py
+++ b/tests-extra/tools/dnstest/update.py
@@ -42,3 +42,8 @@ class Update(object):
         if self.upd.keyring and not resp.had_tsig:
             set_err("INVALID RESPONSE")
             check_log("ERROR: Expected TSIG signed response")
+
+    def query_size(self):
+        '''Return update query size.'''
+
+        return len(self.upd.to_wire())