diff --git a/CHANGELOG.md b/CHANGELOG.md
index 10dd28a8f6a..d7102d2474e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4730,6 +4730,7 @@ Released 2018-09-13
 [`invisible_characters`]: https://rust-lang.github.io/rust-clippy/master/index.html#invisible_characters
 [`is_digit_ascii_radix`]: https://rust-lang.github.io/rust-clippy/master/index.html#is_digit_ascii_radix
 [`items_after_statements`]: https://rust-lang.github.io/rust-clippy/master/index.html#items_after_statements
+[`items_after_test_module`]: https://rust-lang.github.io/rust-clippy/master/index.html#items_after_test_module
 [`iter_cloned_collect`]: https://rust-lang.github.io/rust-clippy/master/index.html#iter_cloned_collect
 [`iter_count`]: https://rust-lang.github.io/rust-clippy/master/index.html#iter_count
 [`iter_kv_map`]: https://rust-lang.github.io/rust-clippy/master/index.html#iter_kv_map
diff --git a/clippy_lints/src/declared_lints.rs b/clippy_lints/src/declared_lints.rs
index f24dab62780..0c66d36a1d6 100644
--- a/clippy_lints/src/declared_lints.rs
+++ b/clippy_lints/src/declared_lints.rs
@@ -215,6 +215,7 @@ pub(crate) static LINTS: &[&crate::LintInfo] = &[
     crate::invalid_upcast_comparisons::INVALID_UPCAST_COMPARISONS_INFO,
     crate::invalid_utf8_in_unchecked::INVALID_UTF8_IN_UNCHECKED_INFO,
     crate::items_after_statements::ITEMS_AFTER_STATEMENTS_INFO,
+    crate::items_after_test_module::ITEMS_AFTER_TEST_MODULE_INFO,
     crate::iter_not_returning_iterator::ITER_NOT_RETURNING_ITERATOR_INFO,
     crate::large_const_arrays::LARGE_CONST_ARRAYS_INFO,
     crate::large_enum_variant::LARGE_ENUM_VARIANT_INFO,
diff --git a/clippy_lints/src/items_after_test_module.rs b/clippy_lints/src/items_after_test_module.rs
new file mode 100644
index 00000000000..9d949a44c02
--- /dev/null
+++ b/clippy_lints/src/items_after_test_module.rs
@@ -0,0 +1,82 @@
+use clippy_utils::{diagnostics::span_lint_and_help, is_in_cfg_test};
+use rustc_hir::{HirId, ItemId, ItemKind, Mod};
+use rustc_lint::{LateContext, LateLintPass, LintContext};
+use rustc_middle::lint::in_external_macro;
+use rustc_session::{declare_lint_pass, declare_tool_lint};
+use rustc_span::sym;
+
+declare_clippy_lint! {
+    /// ### What it does
+    /// Triggers if an item is declared after the testing module marked with `#[cfg(test)]`.
+    /// ### Why is this bad?
+    /// Having items declared after the testing module is confusing and may lead to bad test coverage.
+    /// ### Example
+    /// ```rust
+    /// #[cfg(test)]
+    /// mod tests {
+    ///     // [...]
+    /// }
+    ///
+    /// fn my_function() {
+    ///     // [...]
+    /// }
+    /// ```
+    /// Use instead:
+    /// ```rust
+    /// fn my_function() {
+    ///     // [...]
+    /// }
+    ///
+    /// #[cfg(test)]
+    /// mod tests {
+    ///     // [...]
+    /// }
+    /// ```
+    #[clippy::version = "1.70.0"]
+    pub ITEMS_AFTER_TEST_MODULE,
+    style,
+    "An item was found after the testing module `tests`"
+}
+
+declare_lint_pass!(ItemsAfterTestModule => [ITEMS_AFTER_TEST_MODULE]);
+
+impl LateLintPass<'_> for ItemsAfterTestModule {
+    fn check_mod(&mut self, cx: &LateContext<'_>, _: &Mod<'_>, _: HirId) {
+        let mut was_test_mod_visited = false;
+        let mut test_mod_hash: Option<u128> = None;
+
+        let hir = cx.tcx.hir();
+        let items = hir.items().collect::<Vec<ItemId>>();
+
+        for itid in &items {
+            let item = hir.item(*itid);
+
+            if_chain! {
+            if was_test_mod_visited;
+            if cx.sess().source_map().lookup_char_pos(item.span.lo()).file.name_hash
+            == test_mod_hash.unwrap(); // Will never fail
+            if !matches!(item.kind, ItemKind::Mod(_) | ItemKind::Macro(_, _));
+            if !is_in_cfg_test(cx.tcx, itid.hir_id()); // The item isn't in the testing module itself
+
+            if !in_external_macro(cx.sess(), item.span);
+            then {
+                span_lint_and_help(cx, ITEMS_AFTER_TEST_MODULE, item.span, "an item was found after the testing module", None, "move the item to before the testing module was defined");
+            }};
+
+            if matches!(item.kind, ItemKind::Mod(_)) {
+                for attr in cx.tcx.get_attrs(item.owner_id.to_def_id(), sym::cfg) {
+                    if_chain! {
+                                if attr.has_name(sym::cfg);
+                                if let Some(mitems) = attr.meta_item_list();
+                                if let [mitem] = &*mitems;
+                                if mitem.has_name(sym::test);
+                                then {
+                                    was_test_mod_visited = true;
+                    test_mod_hash = Some(cx.sess().source_map().lookup_char_pos(item.span.lo()).file.name_hash);
+                                }
+                            }
+                }
+            }
+        }
+    }
+}
diff --git a/clippy_lints/src/lib.rs b/clippy_lints/src/lib.rs
index af2c7803601..573ffe349ec 100644
--- a/clippy_lints/src/lib.rs
+++ b/clippy_lints/src/lib.rs
@@ -158,6 +158,7 @@ mod int_plus_one;
 mod invalid_upcast_comparisons;
 mod invalid_utf8_in_unchecked;
 mod items_after_statements;
+mod items_after_test_module;
 mod iter_not_returning_iterator;
 mod large_const_arrays;
 mod large_enum_variant;
@@ -961,6 +962,7 @@ pub fn register_plugins(store: &mut rustc_lint::LintStore, sess: &Session, conf:
     store.register_late_pass(|_| Box::new(tests_outside_test_module::TestsOutsideTestModule));
     store.register_late_pass(|_| Box::new(manual_slice_size_calculation::ManualSliceSizeCalculation));
     store.register_early_pass(|| Box::new(suspicious_doc_comments::SuspiciousDocComments));
+    store.register_late_pass(|_| Box::new(items_after_test_module::ItemsAfterTestModule));
     // add lints here, do not remove this comment, it's used in `new_lint`
 }
 
diff --git a/tests/ui/items_after_test_module.rs b/tests/ui/items_after_test_module.rs
new file mode 100644
index 00000000000..735c06e51ff
--- /dev/null
+++ b/tests/ui/items_after_test_module.rs
@@ -0,0 +1,24 @@
+// compile-flags: --test
+#![allow(unused)]
+#![warn(clippy::items_after_test_module)]
+
+fn main() {}
+
+fn should_not_lint() {}
+
+#[allow(dead_code)]
+#[allow(unused)] // Some attributes to check that span replacement is good enough
+#[allow(clippy::allow_attributes)]
+#[cfg(test)]
+mod tests {
+    #[test]
+    fn hi() {}
+}
+
+fn should_lint() {}
+
+const SHOULD_ALSO_LINT: usize = 1;
+
+macro_rules! should_not_lint {
+    () => {};
+}
diff --git a/tests/ui/items_after_test_module.stderr b/tests/ui/items_after_test_module.stderr
new file mode 100644
index 00000000000..53ca4746476
--- /dev/null
+++ b/tests/ui/items_after_test_module.stderr
@@ -0,0 +1,19 @@
+error: an item was found after the testing module
+  --> $DIR/items_after_test_module.rs:18:1
+   |
+LL | fn should_lint() {}
+   | ^^^^^^^^^^^^^^^^^^^
+   |
+   = help: move the item to before the testing module was defined
+   = note: `-D clippy::items-after-test-module` implied by `-D warnings`
+
+error: an item was found after the testing module
+  --> $DIR/items_after_test_module.rs:20:1
+   |
+LL | const SHOULD_ALSO_LINT: usize = 1;
+   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+   |
+   = help: move the item to before the testing module was defined
+
+error: aborting due to 2 previous errors
+