diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index fbf35d3..2c5434e 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -19,10 +19,10 @@ jobs:
 
     steps:
     - uses: actions/checkout@v3
-    - name: Set up Python 3.8
+    - name: Set up Python 3.11
       uses: actions/setup-python@v3
       with:
-        python-version: "3.8"
+        python-version: "3.11"
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
diff --git a/README.md b/README.md
index 0802e45..e259087 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@ a city's bbox has been extended.
 A single city or a country with few metro networks can be validated much faster
 if you allow the `process_subway.py` to fetch data from Overpass API. Here are the steps:
 
-1. Python3 interpreter required (3.8+)
+1. Python3 interpreter required (3.11+)
 2. Clone the repo
     ```
     git clone https://github.com/alexey-zakharenkov/subways.git subways_validator
@@ -84,8 +84,8 @@ if you allow the `process_subway.py` to fetch data from Overpass API. Here are t
 Summary information about all metro networks that are monitored is gathered in the
 [Google Spreadsheet](https://docs.google.com/spreadsheets/d/1SEW1-NiNOnA2qDwievcxYV1FOaQl1mb1fdeyqAxHu3k).
 
-Regular updates of validation results are available at [Organic Maps](https://cdn.organicmaps.app/subway/) and
-[mail.ru](https://maps.mail.ru/osm/tools/subways/latest/) servers.
+Regular updates of validation results are available at
+[this website](https://maps.mail.ru/osm/tools/subways/latest/).
 You can find more info about this validator instance in
 [OSM Wiki](https://wiki.openstreetmap.org/wiki/Quality_assurance#subway-preprocessor).
 
diff --git a/process_subways.py b/process_subways.py
index 89e1021..c81a21c 100755
--- a/process_subways.py
+++ b/process_subways.py
@@ -11,7 +11,6 @@ import time
 import urllib.parse
 import urllib.request
 from functools import partial
-from typing import Dict, List, Optional, Tuple
 
 import processors
 from subway_io import (
@@ -30,17 +29,18 @@ from subway_structure import (
     MODES_RAPID,
 )
 
-
 DEFAULT_SPREADSHEET_ID = "1SEW1-NiNOnA2qDwievcxYV1FOaQl1mb1fdeyqAxHu3k"
 DEFAULT_CITIES_INFO_URL = (
     "https://docs.google.com/spreadsheets/d/"
     f"{DEFAULT_SPREADSHEET_ID}/export?format=csv"
 )
 
-Point = Tuple[float, float]
+Point = tuple[float, float]
 
 
-def overpass_request(overground, overpass_api, bboxes):
+def overpass_request(
+    overground: bool, overpass_api: str, bboxes: list[list[float]]
+) -> list[dict]:
     query = "[out:json][timeout:1000];("
     modes = MODES_OVERGROUND if overground else MODES_RAPID
     for bbox in bboxes:
@@ -65,7 +65,9 @@ def overpass_request(overground, overpass_api, bboxes):
     return json.load(response)["elements"]
 
 
-def multi_overpass(overground, overpass_api, bboxes):
+def multi_overpass(
+    overground: bool, overpass_api: str, bboxes: list[list[float]]
+) -> list[dict]:
     SLICE_SIZE = 10
     INTERREQUEST_WAIT = 5  # in seconds
     result = []
@@ -77,13 +79,13 @@ def multi_overpass(overground, overpass_api, bboxes):
     return result
 
 
-def slugify(name):
+def slugify(name: str) -> str:
     return re.sub(r"[^a-z0-9_-]+", "", name.lower().replace(" ", "_"))
 
 
 def get_way_center(
-    element: dict, node_centers: Dict[int, Point]
-) -> Optional[Point]:
+    element: dict, node_centers: dict[int, Point]
+) -> Point | None:
     """
     :param element: dict describing OSM element
     :param node_centers: osm_id => (lat, lon)
@@ -123,11 +125,11 @@ def get_way_center(
 
 def get_relation_center(
     element: dict,
-    node_centers: Dict[int, Point],
-    way_centers: Dict[int, Point],
-    relation_centers: Dict[int, Point],
+    node_centers: dict[int, Point],
+    way_centers: dict[int, Point],
+    relation_centers: dict[int, Point],
     ignore_unlocalized_child_relations: bool = False,
-) -> Optional[Point]:
+) -> Point | None:
     """
     :param element: dict describing OSM element
     :param node_centers: osm_id => (lat, lon)
@@ -176,14 +178,14 @@ def get_relation_center(
     return element["center"]["lat"], element["center"]["lon"]
 
 
-def calculate_centers(elements):
+def calculate_centers(elements: list[dict]) -> None:
     """Adds 'center' key to each way/relation in elements,
     except for empty ways or relations.
     Relies on nodes-ways-relations order in the elements list.
     """
-    nodes: Dict[int, Point] = {}  # id => (lat, lon)
-    ways: Dict[int, Point] = {}  # id => (lat, lon)
-    relations: Dict[int, Point] = {}  # id => (lat, lon)
+    nodes: dict[int, Point] = {}  # id => (lat, lon)
+    ways: dict[int, Point] = {}  # id => (lat, lon)
+    relations: dict[int, Point] = {}  # id => (lat, lon)
 
     unlocalized_relations = []  # 'unlocalized' means the center of the
     # relation has not been calculated yet
@@ -202,7 +204,7 @@ def calculate_centers(elements):
 
     def iterate_relation_centers_calculation(
         ignore_unlocalized_child_relations: bool,
-    ) -> List[int]:
+    ) -> list[dict]:
         unlocalized_relations_upd = []
         for rel in unlocalized_relations:
             if center := get_relation_center(
@@ -229,14 +231,16 @@ def calculate_centers(elements):
         unlocalized_relations = unlocalized_relations_upd
 
 
-def add_osm_elements_to_cities(osm_elements, cities):
+def add_osm_elements_to_cities(
+    osm_elements: list[dict], cities: list[City]
+) -> None:
     for el in osm_elements:
         for c in cities:
             if c.contains(el):
                 c.add(el)
 
 
-def validate_cities(cities):
+def validate_cities(cities: list[City]) -> list[City]:
     """Validate cities. Return list of good cities."""
     good_cities = []
     for c in cities:
@@ -266,7 +270,7 @@ def validate_cities(cities):
 
 def get_cities_info(
     cities_info_url: str = DEFAULT_CITIES_INFO_URL,
-) -> List[dict]:
+) -> list[dict]:
     response = urllib.request.urlopen(cities_info_url)
     if (
         not cities_info_url.startswith("file://")
@@ -310,14 +314,14 @@ def get_cities_info(
 
 def prepare_cities(
     cities_info_url: str = DEFAULT_CITIES_INFO_URL, overground: bool = False
-) -> List[City]:
+) -> list[City]:
     if overground:
         raise NotImplementedError("Overground transit not implemented yet")
     cities_info = get_cities_info(cities_info_url)
     return list(map(partial(City, overground=overground), cities_info))
 
 
-def main():
+def main() -> None:
     parser = argparse.ArgumentParser()
     parser.add_argument(
         "--cities-info-url",
diff --git a/scripts/process_subways.sh b/scripts/process_subways.sh
index 1052d51..42c4af6 100755
--- a/scripts/process_subways.sh
+++ b/scripts/process_subways.sh
@@ -1,5 +1,5 @@
-#!/usr/bin/env bash
-#set -euxo pipefail
+#!/bin/bash
+set -e -u
 
 if [ $# -gt 0 -a \( "${1-}" = "-h" -o "${1-}" = '--help' \) ]; then
   cat << EOF
@@ -134,7 +134,7 @@ fi
 if [ -n "${NEED_FILTER-}" ]; then
 
   # If $PLANET_METRO file doesn't exist, create it
-
+  
   if [ -n "${PLANET_METRO-}" ]; then
     EXT=${PLANET_METRO##*.}
     if [ ! "$EXT" = "osm" -a ! "$EXT" == "xml" -a ! "$EXT" = "o5m" ]; then
@@ -235,9 +235,6 @@ if [ -n "${NEED_TO_REMOVE_POLY-}" ]; then
 fi
 
 # Running the validation
-if [ -n "${DUMP-}" ]; then
-  mkdir -p "$DUMP"
-fi
 
 if [ -n "${DUMP-}" ]; then
    mkdir -p "$DUMP"
diff --git a/subway_structure.py b/subway_structure.py
index 63bf63f..86a0f30 100644
--- a/subway_structure.py
+++ b/subway_structure.py
@@ -1,6 +1,7 @@
 import math
 import re
 from collections import Counter, defaultdict
+from itertools import islice
 
 from css_colours import normalize_colour
 
@@ -1161,18 +1162,21 @@ class Route:
         tracks = self.get_truncated_tracks(tracks)
         return tracks
 
-    def check_stops_order_by_angle(self):
+    def check_stops_order_by_angle(self) -> tuple[list, list]:
         disorder_warnings = []
         disorder_errors = []
-        for si in range(len(self.stops) - 2):
+        for i, route_stop in enumerate(
+            islice(self.stops, 1, len(self.stops) - 1), start=1
+        ):
             angle = angle_between(
-                self.stops[si].stop,
-                self.stops[si + 1].stop,
-                self.stops[si + 2].stop,
+                self.stops[i - 1].stop,
+                route_stop.stop,
+                self.stops[i + 1].stop,
             )
             if angle < ALLOWED_ANGLE_BETWEEN_STOPS:
                 msg = (
-                    f'Angle between stops around "{self.stops[si + 1]}" '
+                    "Angle between stops around "
+                    f'"{route_stop.stoparea.name}" {route_stop.stop} '
                     f"is too narrow, {angle} degrees"
                 )
                 if angle < DISALLOWED_ANGLE_BETWEEN_STOPS:
@@ -1181,7 +1185,7 @@ class Route:
                     disorder_warnings.append(msg)
         return disorder_warnings, disorder_errors
 
-    def check_stops_order_on_tracks_direct(self, stop_sequence):
+    def check_stops_order_on_tracks_direct(self, stop_sequence) -> str | None:
         """Checks stops order on tracks, following stop_sequence
         in direct order only.
         :param stop_sequence: list of dict{'route_stop', 'positions_on_rails',
@@ -1206,12 +1210,13 @@ class Route:
                     allowed_order_violations -= 1
                 else:
                     route_stop = stop_data["route_stop"]
-                    return 'Stops on tracks are unordered near "{}" {}'.format(
-                        route_stop.stoparea.name, route_stop.stop
+                    return (
+                        "Stops on tracks are unordered near "
+                        f'"{route_stop.stoparea.name}" {route_stop.stop}'
                     )
             max_position_on_rails = positions_on_rails[suitable_occurrence]
 
-    def check_stops_order_on_tracks(self, projected_stops_data):
+    def check_stops_order_on_tracks(self, projected_stops_data) -> str | None:
         """Checks stops order on tracks, trying direct and reversed
             order of stops in the stop_sequence.
         :param projected_stops_data: info about RouteStops that belong to the
@@ -1517,25 +1522,23 @@ class City:
         self.errors = []
         self.warnings = []
         self.notices = []
-        self.id = int(city_data["id"])
+        self.try_fill_int_attribute(city_data, "id")
         self.name = city_data["name"]
         self.country = city_data["country"]
         self.continent = city_data["continent"]
         self.overground = overground
         if not overground:
-            self.num_stations = int(city_data["num_stations"])
-            self.num_lines = int(city_data["num_lines"] or "0")
-            self.num_light_lines = int(city_data["num_light_lines"] or "0")
-            self.num_interchanges = int(city_data["num_interchanges"] or "0")
+            self.try_fill_int_attribute(city_data, "num_stations")
+            self.try_fill_int_attribute(city_data, "num_lines", "0")
+            self.try_fill_int_attribute(city_data, "num_light_lines", "0")
+            self.try_fill_int_attribute(city_data, "num_interchanges", "0")
         else:
-            self.num_tram_lines = int(city_data["num_tram_lines"] or "0")
-            self.num_trolleybus_lines = int(
-                city_data["num_trolleybus_lines"] or "0"
-            )
-            self.num_bus_lines = int(city_data["num_bus_lines"] or "0")
-            self.num_other_lines = int(city_data["num_other_lines"] or "0")
+            self.try_fill_int_attribute(city_data, "num_tram_lines", "0")
+            self.try_fill_int_attribute(city_data, "num_trolleybus_lines", "0")
+            self.try_fill_int_attribute(city_data, "num_bus_lines", "0")
+            self.try_fill_int_attribute(city_data, "num_other_lines", "0")
 
-        # Aquiring list of networks and modes
+        # Acquiring list of networks and modes
         networks = (
             None
             if not city_data["networks"]
@@ -1574,6 +1577,33 @@ class City:
         self.stops_and_platforms = set()  # Set of stops and platforms el_id
         self.recovery_data = None
 
+    def try_fill_int_attribute(
+        self, city_data: dict, attr: str, default: str | None = None
+    ) -> None:
+        """Try to convert string value to int. Conversion is considered
+        to fail if one of the following is true:
+        * attr is not empty and data type casting fails;
+        * attr is empty and no default value is given.
+        In such cases the city is marked as bad by adding an error
+        to the city validation log.
+        """
+        attr_value = city_data[attr]
+        if not attr_value and default is not None:
+            attr_value = default
+
+        try:
+            attr_int = int(attr_value)
+        except ValueError:
+            print_value = (
+                f"{city_data[attr]}" if city_data[attr] else "<empty>"
+            )
+            self.error(
+                f"Configuration error: wrong value for {attr}: {print_value}"
+            )
+            setattr(self, attr, 0)
+        else:
+            setattr(self, attr, attr_int)
+
     @staticmethod
     def log_message(message, el):
         if el:
@@ -1814,12 +1844,12 @@ class City:
         if not self.overground:
             result.update(
                 {
-                    "subwayl_expected": self.num_lines,
-                    "lightrl_expected": self.num_light_lines,
+                    "subwayl_expected": getattr(self, "num_lines", 0),
+                    "lightrl_expected": getattr(self, "num_light_lines", 0),
                     "subwayl_found": getattr(self, "found_lines", 0),
                     "lightrl_found": getattr(self, "found_light_lines", 0),
-                    "stations_expected": self.num_stations,
-                    "transfers_expected": self.num_interchanges,
+                    "stations_expected": getattr(self, "num_stations", 0),
+                    "transfers_expected": getattr(self, "num_interchanges", 0),
                 }
             )
         else:
@@ -1827,10 +1857,12 @@ class City:
                 {
                     "stations_expected": 0,
                     "transfers_expected": 0,
-                    "busl_expected": self.num_bus_lines,
-                    "trolleybusl_expected": self.num_trolleybus_lines,
-                    "traml_expected": self.num_tram_lines,
-                    "otherl_expected": self.num_other_lines,
+                    "busl_expected": getattr(self, "num_bus_lines", 0),
+                    "trolleybusl_expected": getattr(
+                        self, "num_trolleybus_lines", 0
+                    ),
+                    "traml_expected": getattr(self, "num_tram_lines", 0),
+                    "otherl_expected": getattr(self, "num_other_lines", 0),
                     "busl_found": getattr(self, "found_bus_lines", 0),
                     "trolleybusl_found": getattr(
                         self, "found_trolleybus_lines", 0
@@ -2006,7 +2038,8 @@ class City:
                 )
                 log_function = (
                     self.error
-                    if not (
+                    if self.num_stations > 0
+                    and not (
                         0
                         <= (self.num_stations - self.found_stations)
                         / self.num_stations
diff --git a/tests/assets/networks_with_bad_values.csv b/tests/assets/networks_with_bad_values.csv
new file mode 100644
index 0000000..0d0b528
--- /dev/null
+++ b/tests/assets/networks_with_bad_values.csv
@@ -0,0 +1,12 @@
+#,City,Country,Region,Stations,Subway Lines,Light Rail +Monorail,Interchanges,"BBox (lon, lat)",Networks (opt.),Comment,Source,
+291,Moscow,Russia,Europe,335,14,3,66,,"subway,train:Московский метрополитен;МЦК;МЦД","No bbox - skip the city",
+,Moscow - Aeroexpress,Russia,Europe,22,0,3,0,"37.170,55.385,38.028,56.022",train:Аэроэкспресс,"No id - skip the city",https://aeroexpress.ru
+292,Nizhny Novgorod,Russia,Europe,16,2,0,,"43.759918,56.1662,44.13208,56.410862",,"No configuration errors",,,
+NBS,Novosibirsk,Russia,Europe,13,2,0,1,"82.774773,54.926747,83.059044,55.127864",,"Non-numeric ID",,,
+294,Saint Petersburg,Russia,Europe,72,,,,"30.0648,59.7509,30.5976,60.1292",,"Empty line counts - no problem at CSV parsing stage",,
+,,,,,,,,,,,,
+295,Samara,Russia,Europe,10x,1,0,0,"50.011826,53.094024,50.411453,53.384147",,"Non-numeric station count",,
+296,Volgograd,Russia,Europe,40,zero,2zero,0,"44.366704,48.636024,44.62302,48.81765",,"Non-numbers in subway and light_rail line counts",,
+297,Yekaterinburg,Russia,Europe,,1,0,0,"60.460854,56.730505,60.727272,56.920997",,"Empty station count",,
+,,,,,,,,,,,,
+
diff --git a/tests/sample_data.py b/tests/sample_data_for_build_tracks.py
similarity index 99%
rename from tests/sample_data.py
rename to tests/sample_data_for_build_tracks.py
index 0fffacd..ed1b589 100644
--- a/tests/sample_data.py
+++ b/tests/sample_data_for_build_tracks.py
@@ -37,7 +37,7 @@ sample_networks = {
   </relation>
 </osm>
 """,
-        "station_count": 2,
+        "num_stations": 2,
         "tracks": [],
         "extended_tracks": [
             (0.0, 0.0),
@@ -100,7 +100,7 @@ sample_networks = {
   </relation>
 </osm>
 """,
-        "station_count": 2,
+        "num_stations": 2,
         "tracks": [
             (0.0, 0.0),
             (1.0, 0.0),
@@ -190,7 +190,7 @@ sample_networks = {
   </relation>
 </osm>
 """,
-        "station_count": 6,
+        "num_stations": 6,
         "tracks": [],
         "extended_tracks": [
             (0.0, 0.0),
@@ -289,7 +289,7 @@ sample_networks = {
   </relation>
 </osm>
 """,
-        "station_count": 6,
+        "num_stations": 6,
         "tracks": [
             (0.0, 0.0),
             (1.0, 0.0),
@@ -401,7 +401,7 @@ sample_networks = {
   </relation>
 </osm>
 """,
-        "station_count": 6,
+        "num_stations": 6,
         "tracks": [
             (0.0, 0.0),
             (1.0, 0.0),
@@ -511,7 +511,7 @@ sample_networks = {
   </relation>
 </osm>
 """,
-        "station_count": 6,
+        "num_stations": 6,
         "tracks": [
             (1.0, 0.0),
             (2.0, 0.0),
@@ -620,7 +620,7 @@ sample_networks = {
   </relation>
 </osm>
 """,
-        "station_count": 6,
+        "num_stations": 6,
         "tracks": [
             (1.0, 0.0),
             (2.0, 0.0),
@@ -725,7 +725,7 @@ sample_networks = {
   </relation>
 </osm>
 """,
-        "station_count": 6,
+        "num_stations": 6,
         "tracks": [
             (0.0, 0.0),
             (1.0, 0.0),
@@ -826,7 +826,7 @@ sample_networks = {
   </relation>
 </osm>
 """,
-        "station_count": 6,
+        "num_stations": 6,
         "tracks": [
             (4.0, 0.0),
             (5.0, 0.0),
@@ -937,7 +937,7 @@ sample_networks = {
   </relation>
 </osm>
 """,
-        "station_count": 6,
+        "num_stations": 6,
         "tracks": [
             (-1.0, 0.0),
             (0.0, 0.0),
@@ -1069,7 +1069,7 @@ sample_networks = {
   </relation>
 </osm>
 """,
-        "station_count": 6,
+        "num_stations": 6,
         "tracks": [
             (-1.0, 0.0),
             (0.0, 0.0),
@@ -1189,7 +1189,7 @@ sample_networks = {
   </relation>
 </osm>
 """,
-        "station_count": 6,
+        "num_stations": 6,
         "tracks": [
             (-1.0, 0.0),
             (6.0, 0.0),
@@ -1304,7 +1304,7 @@ sample_networks = {
   </relation>
 </osm>
 """,
-        "station_count": 6,
+        "num_stations": 6,
         "tracks": [
             (1.0, 0.0),
             (4.0, 0.0),
@@ -1377,7 +1377,7 @@ sample_networks = {
   </relation>
 </osm>
 """,
-        "station_count": 4,
+        "num_stations": 4,
         "tracks": [],
         "extended_tracks": [
             (0.0, 0.0),
@@ -1455,7 +1455,7 @@ sample_networks = {
   </relation>
 </osm>
 """,
-        "station_count": 4,
+        "num_stations": 4,
         "tracks": [
             (0.0, 0.0),
             (0.0, 1.0),
diff --git a/tests/sample_data_for_error_messages.py b/tests/sample_data_for_error_messages.py
new file mode 100644
index 0000000..9d5c5fc
--- /dev/null
+++ b/tests/sample_data_for_error_messages.py
@@ -0,0 +1,316 @@
+sample_networks = {
+    "No errors": {
+        "xml": """<?xml version='1.0' encoding='UTF-8'?>
+<osm version='0.6' generator='JOSM'>
+  <node id='1' version='1' lat='0.0' lon='0.0'>
+    <tag k='name' v='Station 1' />
+    <tag k='railway' v='station' />
+    <tag k='station' v='subway' />
+  </node>
+  <node id='2' version='1' lat='0.0' lon='1.0'>
+    <tag k='name' v='Station 2' />
+    <tag k='railway' v='station' />
+    <tag k='station' v='subway' />
+  </node>
+  <relation id='1' version='1'>
+    <member type='node' ref='1' role='' />
+    <member type='node' ref='2' role='' />
+    <tag k='name' v='Forward' />
+    <tag k='ref' v='1' />
+    <tag k='route' v='subway' />
+    <tag k='type' v='route' />
+  </relation>
+  <relation id='2' version='1'>
+    <member type='node' ref='2' role='' />
+    <member type='node' ref='1' role='' />
+    <tag k='name' v='Backward' />
+    <tag k='ref' v='1' />
+    <tag k='route' v='subway' />
+    <tag k='type' v='route' />
+  </relation>
+  <relation id='3' version='1'>
+    <member type='relation' ref='1' role='' />
+    <member type='relation' ref='2' role='' />
+    <tag k='ref' v='1' />
+    <tag k='colour' v='red' />
+    <tag k='route_master' v='subway' />
+    <tag k='type' v='route_master' />
+  </relation>
+</osm>
+""",
+        "num_stations": 2,
+        "num_lines": 1,
+        "num_light_lines": 0,
+        "num_interchanges": 0,
+        "errors": [],
+        "warnings": [],
+        "notices": [],
+    },
+    "Bad station order": {
+        "xml": """<?xml version='1.0' encoding='UTF-8'?>
+<osm version='0.6' generator='JOSM'>
+  <node id='1' version='1' lat='0.0' lon='0.0'>
+    <tag k='name' v='Station 1' />
+    <tag k='railway' v='station' />
+    <tag k='station' v='subway' />
+  </node>
+  <node id='2' version='1' lat='0.0' lon='1.0'>
+    <tag k='name' v='Station 2' />
+    <tag k='railway' v='station' />
+    <tag k='station' v='subway' />
+  </node>
+  <node id='3' version='1' lat='0.0' lon='2.0'>
+    <tag k='name' v='Station 3' />
+    <tag k='railway' v='station' />
+    <tag k='station' v='subway' />
+  </node>
+  <node id='4' version='1' lat='0.0' lon='3.0'>
+    <tag k='name' v='Station 4' />
+    <tag k='railway' v='station' />
+    <tag k='station' v='subway' />
+  </node>
+  <relation id='1' version='1'>
+    <member type='node' ref='1' role='' />
+    <member type='node' ref='3' role='' />
+    <member type='node' ref='2' role='' />
+    <member type='node' ref='4' role='' />
+    <tag k='name' v='Forward' />
+    <tag k='ref' v='1' />
+    <tag k='route' v='subway' />
+    <tag k='type' v='route' />
+  </relation>
+  <relation id='2' version='1'>
+    <member type='node' ref='4' role='' />
+    <member type='node' ref='3' role='' />
+    <member type='node' ref='2' role='' />
+    <member type='node' ref='1' role='' />
+    <tag k='name' v='Backward' />
+    <tag k='ref' v='1' />
+    <tag k='route' v='subway' />
+    <tag k='type' v='route' />
+  </relation>
+  <relation id='3' version='1'>
+    <member type='relation' ref='1' role='' />
+    <member type='relation' ref='2' role='' />
+    <tag k='ref' v='1' />
+    <tag k='colour' v='red' />
+    <tag k='route_master' v='subway' />
+    <tag k='type' v='route_master' />
+  </relation>
+</osm>
+""",
+        "num_stations": 4,
+        "num_lines": 1,
+        "num_light_lines": 0,
+        "num_interchanges": 0,
+        "errors": [
+            'Angle between stops around "Station 3" (2.0, 0.0) '
+            'is too narrow, 0 degrees (relation 1, "Forward")',
+            'Angle between stops around "Station 2" (1.0, 0.0) '
+            'is too narrow, 0 degrees (relation 1, "Forward")',
+        ],
+        "warnings": [],
+        "notices": [],
+    },
+    "Angle < 20 degrees": {
+        "xml": """<?xml version='1.0' encoding='UTF-8'?>
+<osm version='0.6' generator='JOSM'>
+  <node id='1' version='1' lat='0.0' lon='0.0'>
+    <tag k='name' v='Station 1' />
+    <tag k='railway' v='station' />
+    <tag k='station' v='subway' />
+  </node>
+  <node id='2' version='1' lat='0.0' lon='1.0'>
+    <tag k='name' v='Station 2' />
+    <tag k='railway' v='station' />
+    <tag k='station' v='subway' />
+  </node>
+  <node id='3' version='1' lat='0.2' lon='0.0'>
+    <tag k='name' v='Station 3' />
+    <tag k='railway' v='station' />
+    <tag k='station' v='subway' />
+  </node>
+  <relation id='1' version='1'>
+    <member type='node' ref='1' role='' />
+    <member type='node' ref='2' role='' />
+    <member type='node' ref='3' role='' />
+    <tag k='name' v='Forward' />
+    <tag k='ref' v='1' />
+    <tag k='route' v='subway' />
+    <tag k='type' v='route' />
+  </relation>
+  <relation id='2' version='1'>
+    <member type='node' ref='3' role='' />
+    <member type='node' ref='2' role='' />
+    <member type='node' ref='1' role='' />
+    <member type='way' ref='1' role='' />
+    <tag k='name' v='Backward' />
+    <tag k='ref' v='1' />
+    <tag k='route' v='subway' />
+    <tag k='type' v='route' />
+  </relation>
+  <relation id='3' version='1'>
+    <member type='relation' ref='1' role='' />
+    <member type='relation' ref='2' role='' />
+    <tag k='ref' v='1' />
+    <tag k='colour' v='red' />
+    <tag k='route_master' v='subway' />
+    <tag k='type' v='route_master' />
+  </relation>
+</osm>
+""",
+        "num_stations": 3,
+        "num_lines": 1,
+        "num_light_lines": 0,
+        "num_interchanges": 0,
+        "errors": [
+            'Angle between stops around "Station 2" (1.0, 0.0) '
+            'is too narrow, 11 degrees (relation 1, "Forward")',
+            'Angle between stops around "Station 2" (1.0, 0.0) '
+            'is too narrow, 11 degrees (relation 2, "Backward")',
+        ],
+        "warnings": [],
+        "notices": [],
+    },
+    "Angle between 20 and 45 degrees": {
+        "xml": """<?xml version='1.0' encoding='UTF-8'?>
+<osm version='0.6' generator='JOSM'>
+  <node id='1' version='1' lat='0.0' lon='0.0'>
+    <tag k='name' v='Station 1' />
+    <tag k='railway' v='station' />
+    <tag k='station' v='subway' />
+  </node>
+  <node id='2' version='1' lat='0.0' lon='1.0'>
+    <tag k='name' v='Station 2' />
+    <tag k='railway' v='station' />
+    <tag k='station' v='subway' />
+  </node>
+  <node id='3' version='1' lat='0.5' lon='0.0'>
+    <tag k='name' v='Station 3' />
+    <tag k='railway' v='station' />
+    <tag k='station' v='subway' />
+  </node>
+  <relation id='1' version='1'>
+    <member type='node' ref='1' role='' />
+    <member type='node' ref='2' role='' />
+    <member type='node' ref='3' role='' />
+    <tag k='name' v='Forward' />
+    <tag k='ref' v='1' />
+    <tag k='route' v='subway' />
+    <tag k='type' v='route' />
+  </relation>
+  <relation id='2' version='1'>
+    <member type='node' ref='3' role='' />
+    <member type='node' ref='2' role='' />
+    <member type='node' ref='1' role='' />
+    <member type='way' ref='1' role='' />
+    <tag k='name' v='Backward' />
+    <tag k='ref' v='1' />
+    <tag k='route' v='subway' />
+    <tag k='type' v='route' />
+  </relation>
+  <relation id='3' version='1'>
+    <member type='relation' ref='1' role='' />
+    <member type='relation' ref='2' role='' />
+    <tag k='ref' v='1' />
+    <tag k='colour' v='red' />
+    <tag k='route_master' v='subway' />
+    <tag k='type' v='route_master' />
+  </relation>
+</osm>
+""",
+        "num_stations": 3,
+        "num_lines": 1,
+        "num_light_lines": 0,
+        "num_interchanges": 0,
+        "errors": [],
+        "warnings": [],
+        "notices": [
+            'Angle between stops around "Station 2" (1.0, 0.0) '
+            'is too narrow, 27 degrees (relation 1, "Forward")',
+            'Angle between stops around "Station 2" (1.0, 0.0) '
+            'is too narrow, 27 degrees (relation 2, "Backward")',
+        ],
+    },
+    "Stops unordered along tracks provided each angle > 45 degrees": {
+        "xml": """<?xml version='1.0' encoding='UTF-8'?>
+<osm version='0.6' generator='JOSM'>
+  <node id='1' version='1' lat='0.0' lon='0.0'>
+    <tag k='name' v='Station 1' />
+    <tag k='railway' v='station' />
+    <tag k='station' v='subway' />
+  </node>
+  <node id='2' version='1' lat='0.0' lon='1.0'>
+    <tag k='name' v='Station 2' />
+    <tag k='railway' v='station' />
+    <tag k='station' v='subway' />
+  </node>
+  <node id='3' version='1' lat='0.5' lon='0.0'>
+    <tag k='name' v='Station 3' />
+    <tag k='railway' v='station' />
+    <tag k='station' v='subway' />
+  </node>
+  <node id='4' version='1' lat='1.0' lon='1.0'>
+    <tag k='name' v='Station 4' />
+    <tag k='railway' v='station' />
+    <tag k='station' v='subway' />
+  </node>
+  <way id='1' version='1'>
+    <nd ref='1' />
+    <nd ref='2' />
+    <nd ref='3' />
+    <tag k='railway' v='subway' />
+  </way>
+  <way id='2' version='1'>
+    <nd ref='3' />
+    <nd ref='4' />
+    <tag k='railway' v='subway' />
+  </way>
+  <relation id='1' version='1'>
+    <member type='node' ref='1' role='' />
+    <member type='node' ref='3' role='' />
+    <member type='node' ref='2' role='' />
+    <member type='node' ref='4' role='' />
+    <member type='way' ref='1' role='' />
+    <member type='way' ref='2' role='' />
+    <tag k='name' v='Forward' />
+    <tag k='ref' v='1' />
+    <tag k='route' v='subway' />
+    <tag k='type' v='route' />
+  </relation>
+  <relation id='2' version='1'>
+    <member type='node' ref='4' role='' />
+    <member type='node' ref='2' role='' />
+    <member type='node' ref='3' role='' />
+    <member type='node' ref='1' role='' />
+    <member type='way' ref='2' role='' />
+    <member type='way' ref='1' role='' />
+    <tag k='name' v='Backward' />
+    <tag k='ref' v='1' />
+    <tag k='route' v='subway' />
+    <tag k='type' v='route' />
+  </relation>
+  <relation id='3' version='1'>
+    <member type='relation' ref='1' role='' />
+    <member type='relation' ref='2' role='' />
+    <tag k='ref' v='1' />
+    <tag k='colour' v='red' />
+    <tag k='route_master' v='subway' />
+    <tag k='type' v='route_master' />
+  </relation>
+</osm>
+""",
+        "num_stations": 4,
+        "num_lines": 1,
+        "num_light_lines": 0,
+        "num_interchanges": 0,
+        "errors": [
+            'Stops on tracks are unordered near "Station 2" (1.0, 0.0) '
+            '(relation 1, "Forward")',
+            'Stops on tracks are unordered near "Station 3" (0.0, 0.5) '
+            '(relation 2, "Backward")',
+        ],
+        "warnings": [],
+        "notices": [],
+    },
+}
diff --git a/tests/test_build_tracks.py b/tests/test_build_tracks.py
index da16780..14ea86b 100644
--- a/tests/test_build_tracks.py
+++ b/tests/test_build_tracks.py
@@ -9,52 +9,16 @@ or simply
 > python -m unittest
 """
 
-import io
-import unittest
 
-from subway_structure import City
-from subway_io import load_xml
-from tests.sample_data import sample_networks
+from tests.sample_data_for_build_tracks import sample_networks
+from tests.util import TestCase
 
 
-class TestOneRouteTracks(unittest.TestCase):
+class TestOneRouteTracks(TestCase):
     """Test tracks extending and truncating on one-route networks"""
 
-    CITY_TEMPLATE = {
-        "id": 1,
-        "name": "Null Island",
-        "country": "World",
-        "continent": "Africa",
-        "num_stations": None,  # Would be taken from the sample network data
-        "num_lines": 1,
-        "num_light_lines": 0,
-        "num_interchanges": 0,
-        "bbox": "-179, -89, 179, 89",
-        "networks": "",
-    }
-
-    def assertListAlmostEqual(self, list1, list2, places=10) -> None:
-        if not (isinstance(list1, list) and isinstance(list2, list)):
-            raise RuntimeError(
-                f"Not lists passed to the '{self.__class__.__name__}."
-                "assertListAlmostEqual' method"
-            )
-        self.assertEqual(len(list1), len(list2))
-        for a, b in zip(list1, list2):
-            if isinstance(a, list) and isinstance(b, list):
-                self.assertListAlmostEqual(a, b, places)
-            else:
-                self.assertAlmostEqual(a, b, places)
-
     def prepare_city_routes(self, network) -> tuple:
-        city_data = self.CITY_TEMPLATE.copy()
-        city_data["num_stations"] = network["station_count"]
-        city = City(city_data)
-        elements = load_xml(io.BytesIO(network["xml"].encode("utf-8")))
-        for el in elements:
-            city.add(el)
-        city.extract_routes()
-        city.validate()
+        city = self.validate_city(network)
 
         self.assertTrue(city.is_good)
 
diff --git a/tests/test_error_messages.py b/tests/test_error_messages.py
new file mode 100644
index 0000000..12a5583
--- /dev/null
+++ b/tests/test_error_messages.py
@@ -0,0 +1,22 @@
+from tests.sample_data_for_error_messages import sample_networks
+from tests.util import TestCase
+
+
+class TestValidationMessages(TestCase):
+    """Test that the validator provides expected validation messages
+    on different types of errors in input OSM data.
+    """
+
+    def _test_validation_messages_for_network(self, network_data):
+        city = self.validate_city(network_data)
+
+        for err_level in ("errors", "warnings", "notices"):
+            self.assertListEqual(
+                sorted(getattr(city, err_level)),
+                sorted(network_data[err_level]),
+            )
+
+    def test_validation_messages(self) -> None:
+        for network_name, network_data in sample_networks.items():
+            with self.subTest(msg=network_name):
+                self._test_validation_messages_for_network(network_data)
diff --git a/tests/test_prepare_cities.py b/tests/test_prepare_cities.py
new file mode 100644
index 0000000..e74505f
--- /dev/null
+++ b/tests/test_prepare_cities.py
@@ -0,0 +1,36 @@
+import inspect
+from pathlib import Path
+from unittest import TestCase
+
+from process_subways import prepare_cities
+
+
+class TestPrepareCities(TestCase):
+    def test_prepare_cities(self) -> None:
+        csv_path = (
+            Path(inspect.getfile(self.__class__)).parent
+            / "assets"
+            / "networks_with_bad_values.csv"
+        )
+
+        cities = prepare_cities(cities_info_url=f"file://{csv_path}")
+
+        city_errors = {city.name: sorted(city.errors) for city in cities}
+
+        expected_errors = {
+            "Nizhny Novgorod": [],
+            "Novosibirsk": ["Configuration error: wrong value for id: NBS"],
+            "Saint Petersburg": [],
+            "Samara": [
+                "Configuration error: wrong value for num_stations: 10x"
+            ],
+            "Volgograd": [
+                "Configuration error: wrong value for num_light_lines: 2zero",
+                "Configuration error: wrong value for num_lines: zero",
+            ],
+            "Yekaterinburg": [
+                "Configuration error: wrong value for num_stations: <empty>"
+            ],
+        }
+
+        self.assertDictEqual(city_errors, expected_errors)
diff --git a/tests/util.py b/tests/util.py
new file mode 100644
index 0000000..efab8c2
--- /dev/null
+++ b/tests/util.py
@@ -0,0 +1,49 @@
+import io
+from unittest import TestCase as unittestTestCase
+
+from subway_io import load_xml
+from subway_structure import City
+
+
+class TestCase(unittestTestCase):
+    """TestCase class for testing the Subway Validator"""
+
+    CITY_TEMPLATE = {
+        "id": 1,
+        "name": "Null Island",
+        "country": "World",
+        "continent": "Africa",
+        "bbox": "-179, -89, 179, 89",
+        "networks": "",
+        "num_stations": None,
+        "num_lines": 1,
+        "num_light_lines": 0,
+        "num_interchanges": 0,
+    }
+
+    def validate_city(self, network) -> City:
+        city_data = self.CITY_TEMPLATE.copy()
+        for attr in self.CITY_TEMPLATE.keys():
+            if attr in network:
+                city_data[attr] = network[attr]
+
+        city = City(city_data)
+        elements = load_xml(io.BytesIO(network["xml"].encode("utf-8")))
+        for el in elements:
+            city.add(el)
+        city.extract_routes()
+        city.validate()
+        return city
+
+    def assertListAlmostEqual(self, list1, list2, places=10) -> None:
+        if not (isinstance(list1, list) and isinstance(list2, list)):
+            raise RuntimeError(
+                f"Not lists passed to the '{self.__class__.__name__}."
+                "assertListAlmostEqual' method"
+            )
+        self.assertEqual(len(list1), len(list2))
+        for a, b in zip(list1, list2):
+            if isinstance(a, list) and isinstance(b, list):
+                self.assertListAlmostEqual(a, b, places)
+            else:
+                self.assertAlmostEqual(a, b, places)