В прошлой статье мы познакомились с тестами для Django и создали личного пользователя-тестировщика. Самое время продолжить изучать тестирование сайта, написав проверку русских символов на английских страницах и разобрав тесты для JavaScript.

Тестируем перевод текста

Когда страница, предназначенная для иностранного пользователя, содержит русский текст (или наоборот), это, мягко говоря, не очень приятно. Поэтому лучше такие ситуации протестировать и вовремя избавиться от них.

Наши тесты перевода делятся на два типа:

  1. Проверка отсутствия русских символов на английских страницах;

  2. Проверка отсутствия ошибок в django.po файлах.

Проверка отсутствия русских символов на английских страницах

Перейдём к первому тесту. Его полный код выглядит так:

from requests import get
from bs4 import BeautifulSoup
from bs4.element import Tag

from django.test import TestCase

# А
CODE_OF_FIRST_RUSSIAN_LETTER = 1040

# я
CODE_OF_LAST_RUSSIAN_LETTER = 1103

CODES_OF_RUSSIAN_SYMBOLS = list(
    range(
        CODE_OF_FIRST_RUSSIAN_LETTER,
        CODE_OF_LAST_RUSSIAN_LETTER + 1
    )
) + [1025, 1105] # Ё, ё

ENGLISH_PAGES = (
    'https://pvs-studio.com/ru/',
    'https://pvs-studio.com/ru/blog/posts/',
    'https://pvs-studio.com/ru/for-clients/',
    'https://pvs-studio.com/ru/docs/',
    # ...
)

def is_russian_symbol(symbol: str) -> bool:
    """True if symbol is Russian"""

    return ord(symbol) in CODES_OF_RUSSIAN_SYMBOLS

def get_page_words(content: Tag) -> tuple:
    """Return page's words"""

    page_text = content.get_text()
    page_text_without_extra_spaces = " ".join(page_text.split())
    page_words = page_text_without_extra_spaces.split()

    return tuple(page_words)

class CorrectTranslationTests(TestCase):
    """Test correct translation"""

    def test_russian_symbols_presence(self):
        """Test English pages for presence of Russian symbols"""

        for page in ENGLISH_PAGES:
            page_content = BeautifulSoup(get(page).content, 'html.parser')
            main_div_content = page_content.find(
                'div', {"class": 'b-content'}
            )

            if main_div_content:
                page_words = get_page_words(main_div_content)

                for word in page_words:
                    for symbol in word:
                        error_message = f'\n"{symbol}" ({word}) on {page}\n'

                        with self.subTest(error_message):
                            self.assertFalse(is_russian_symbol(symbol))

Разберём его детально.

Импорты

Для получения контента со страницы нам нужно отправить на неё GET-запрос. С этим успешно справляется метод get.

from requests import get

Чтобы получать контент конкретного блока страницы, нам понадобится Beautiful Soup. Для type hinting импортируем Tag.

from bs4 import BeautifulSoup
from bs4.element import Tag

И также не забудем про TestCase, который позволяет создавать тесты.

from django.test import TestCase

Константы

У каждого символа есть его числовой код. В Python этот код можно получить с помощью функции ord. Коды русских символов имеют диапазон от 1040 (А) до 1103 (я). Также к ним относятся 1025 (Ё) и 1105 (ё). Именно эти коды мы заносим в переменную CODES_OF_RUSSIAN_SYMBOLS. С её помощью будет осуществляться поиск русских символов.

# А
CODE_OF_FIRST_RUSSIAN_LETTER = 1040

# я
CODE_OF_LAST_RUSSIAN_LETTER = 1103

CODES_OF_RUSSIAN_SYMBOLS = list(
    range(
        CODE_OF_FIRST_RUSSIAN_LETTER,
        CODE_OF_LAST_RUSSIAN_LETTER + 1
    )
) + [1025, 1105] # Ё, ё

В ENGLISH_PAGES мы заносим страницы, которые будем проверять:

ENGLISH_PAGES = (
    'https://pvs-studio.com/ru/',
    'https://pvs-studio.com/ru/blog/posts/',
    'https://pvs-studio.com/ru/for-clients/',
    'https://pvs-studio.com/ru/docs/',
    # ...
)

Функция is_russian_symbol

Проверку на русский символ выполняет функция is_russian_symbol. Она получает код и проверяет его наличие в переменной CODES_OF_RUSSIAN_SYMBOLS.

def is_russian_symbol(symbol: str) -> bool:
    """True if symbol is Russian"""

    return ord(symbol) in CODES_OF_RUSSIAN_SYMBOLS

Функция get_page_words

Контент страницы содержит помимо текста ещё и ненужные нам элементы. Например, теги. Чтобы убрать их, у BeautifulSoup есть крутой метод get_text. Он извлекает только текст из HTML-разметки (1). Мы могли бы использовать лишь этот метод, но текст, который он выдаёт, содержит большое число подряд идущих пробелов. Поэтому их мы заменяем на одиночные с помощью split и join (2). Получив строку, мы разбиваем её на список слов (3), чтобы в дальнейшем итерироваться по нему.

def get_page_words(content: Tag) -> tuple:
    """Return page's words"""

    page_text = content.get_text() # (1)
    page_text_without_extra_spaces = " ".join(page_text.split()) # (2)
    page_words = page_text_without_extra_spaces.split() # (3)

    return tuple(page_words)

Функция теста

Тест работает так:

  1. Обходит все английские страницы;

  2. Из каждой страницы извлекает весь контент (похожий пример был в прошлой статье);

  3. На нашем сайте большая часть текста, который видит пользователь, хранится в div с классом b-content. Поэтому тест извлекает контент из него с помощью метода find. Остальные блоки div мы тестируем отдельно;

  4. Получает из контента все слова;

  5. Проходится по каждому слову и по каждому символу;

  6. Проверяет, что символ не является русским.

class CorrectTranslationTests(TestCase):
    """Test correct translation"""

    def test_russian_symbols_presence(self):
        """Test English pages for presence of Russian symbols"""

        for page in ENGLISH_PAGES: # (1)
            # (2)
            page_content = BeautifulSoup(get(page).content, 'html.parser')
            main_div_content = page_content.find(
                'div', {"class": 'b-content'}
            ) # (3)

            if main_div_content:
                page_words = get_page_words(main_div_content) # (4)

                for word in page_words: # (5)
                    for symbol in word: # (5)
                        error_message = f'\n"{symbol}" ({word}) on {page}\n'

                        with self.subTest(error_message):
                            # (6)
                            self.assertFalse(is_russian_symbol(symbol))

Запуск теста

Запустив сейчас тест, мы не увидим ошибок. Чтобы убедиться, что он действительно работает, проверим его для русской страницы.

Проверка отсутствия ошибок в django.po файлах

Возможно, увидев этот заголовок, вы задались вопросом: «Зачем тестировать файлы django.po, если мы уже напрямую проверяем отсутствие русских символов на английских страницах?». На это есть минимум 2 причины:

  1. Тест django.po помогает отловить часть ошибок и выполняется быстро. А значит, его можно проводить после каждого коммита;

  2. Если перевод фразы – пустая строка (т. е. msgstr хранит пустую строку), тест файла django.po поможет это обнаружить, в отличие от теста, написанного ранее.

Всего мы будем проверять 3 условия. А именно то, что в файле django.po не должно быть:

  1. Строки с пустым значением msgstr;

  2. Строки, начинающейся с «msgid»;

  3. Строки, начинающейся с «#, fuzzy».

Невыполнение хотя бы одного из этих пунктов говорит о том, что компиляция перевода выполнена с ошибками.

Полный тест django.po файлов выглядит так:

from django.test import TestCase

MY_PROJECT_LOCALE_PATH = 'my_project/locale/'

DJANGO_PO_PATH = 'LC_MESSAGES/django.po'

RU_DJANGO_PO_PATH = MY_PROJECT_LOCALE_PATH + 'ru/' + DJANGO_PO_PATH

EN_DJANGO_PO_PATH = MY_PROJECT_LOCALE_PATH + 'en/' + DJANGO_PO_PATH

def is_msgstr_empty(line_with_msgstr: str, next_line: str) -> bool:
    """True if msgstr is empty"""

    return 'msgstr ""' in line_with_msgstr and next_line == '\n'

def get_msgstr_errors(file: list) -> tuple:
    """Return numbers of lines where msgstr is empty"""

    errors = []

    for number in range(len(file) - 1):
        line = file[number]
        next_line = file[number + 1]

        if is_msgstr_empty(line, next_line):
            line_number = number + 1

            errors.append(line_number)

    return tuple(errors)

def get_fuzzy_and_msgid_errors(file: list) -> tuple:
    """Return numbers of lines that starts with '#, fuzzy' or #~ msgid"""

    errors = []

    for line_number, line in enumerate(file, 1):
        if line.startswith('#, fuzzy') or line.startswith('#~ msgid'):
            errors.append(line_number)

    return tuple(errors)

def get_locale_files_errors() -> dict:
    """Return errors for ru and en django.po files"""

    with open(RU_DJANGO_PO_PATH, 'r') as file:
        ru_file = list(file).copy()

    with open(EN_DJANGO_PO_PATH, 'r') as file:
        en_file = list(file).copy()

    errors = {
        'ru': get_fuzzy_and_msgid_errors(ru_file) + get_msgstr_errors(ru_file),
        'en': get_fuzzy_and_msgid_errors(en_file) + get_msgstr_errors(en_file),
    }

    return errors

class LocaleTests(TestCase):
    """Tests for locale files"""

    def test_locale_files(self):
        """Test en and ru django.po files"""

        for language, errors in get_locale_files_errors().items():
            with self.subTest(f'Errors for {language}: {sorted(errors)}'):
                self.assertEqual(len(errors), 0)

Рассмотрим, что в нём происходит.

Константы

В них мы просто формируем русский и английский пути к django.po файлам. Например, первый будет таким: pvs/locale/ru/LC_MESSAGES/django.po.

MY_PROJECT_LOCALE_PATH = 'my_project/locale/'

DJANGO_PO_PATH = 'LC_MESSAGES/django.po'

RU_DJANGO_PO_PATH = MY_PROJECT_LOCALE_PATH + 'ru/' + DJANGO_PO_PATH

EN_DJANGO_PO_PATH = MY_PROJECT_LOCALE_PATH + 'en/' + DJANGO_PO_PATH

Функция is_msgstr_empty

Первая функция проверяет, есть ли в строке msgstr с пустым значением.

def is_msgstr_empty(line_with_msgstr: str, next_line: str) -> bool:
    """True if msgstr is empty"""

    return 'msgstr ""' in line_with_msgstr and next_line == '\n'
И тут вроде бы всё понятно, но для чего часть «and next_line == '\n'»? Всё просто. Если содержимое msgstr будет очень длинным, Django перенесёт его на другую строку. В итоге, строка, которую мы проверяем, будет содержать часть «msgstr ""», но она не будет ошибочной, поскольку перевод указан ниже.

Если же перевод будет маленьким, то он останется на одной строке с msgstr. А следующая строка будет равна «\n».

Функция get_msgstr_errors

Перейдём к функции get_msgstr_errors. Она выдаёт номера строк, где msgstr имеет пустое значение. Принцип работы функции, следующий:

  1. Проходится по строкам файла;

  2. Заносит текущую строку в переменную line;

  3. Заносит следующую строку в переменную next_line;

  4. С помощью ранее описанной функции проверяет, содержит ли текущая строка пустой msgstr;

  5. Если да, то заносит номер строки в общий список.

def get_msgstr_errors(file: list) -> tuple:
    """Return numbers of lines where msgstr is empty"""

    errors = []

    for number in range(len(file) - 1): # (1)
        line = file[number] # (2)
        next_line = file[number + 1] # (3)

        if is_msgstr_empty(line, next_line): # (4)
            line_number = number + 1

            errors.append(line_number) # (5)

    return tuple(errors)

Функция get_fuzzy_and_msgid_errors

Идём дальше. Функция get_fuzzy_and_msgid_errors обходит строки файла и проверяет, что они не начинаются с «#, fuzzy» или с «#~ msgid». Если это не так — добавляет номер строки в список ошибок.

def get_fuzzy_and_msgid_errors(file: list) -> tuple:
    """Return numbers of lines that starts with '#, fuzzy' or #~ msgid"""

    errors = []

    for line_number, line in enumerate(file, 1):
        if line.startswith('#, fuzzy') or line.startswith('#~ msgid'):
            errors.append(line_number)

    return tuple(errors)

Функция get_locale_files_errors

get_locale_files_errors выдаёт словарь с языком файла django.po и его ошибками.

def get_locale_files_errors() -> dict:
    """Return errors for ru and en django.po files"""

    with open(RU_DJANGO_PO_PATH, 'r') as file:
        ru_file = list(file).copy()

    with open(EN_DJANGO_PO_PATH, 'r') as file:
        en_file = list(file).copy()

    errors = {
        'ru': get_fuzzy_and_msgid_errors(ru_file) + get_msgstr_errors(ru_file),
        'en': get_fuzzy_and_msgid_errors(en_file) + get_msgstr_errors(en_file),
    }

    return errors

Функция теста

Перейдём к самому тесту. Вот что он делает:

  1. Проходится по словарю с языком и ошибками django.po файла;

  2. Проверяет, что число ошибок равно 0.

    class LocaleTests(TestCase):
        """Tests for locale files"""
    
        def test_locale_files(self):
            """Test en and ru django.po files"""
    
            for language, errors in get_locale_files_errors().items(): # (1)
                with self.subTest(f'Errors for {language}: {sorted(errors)}'):
                    self.assertEqual(len(errors), 0) # (2)

Запуск теста

Проверим тест для следующего django.po файла:

# ...

msgid "Фраза с корректным переводом"
msgstr "Phrase with correct translation"

msgid "Для этой фразы нет перевода"
msgstr ""

#, fuzzy
#| msgid "Фраза 1. Раньше она была такой"
msgid "Фраза 2. Теперь она поменялась на такую"
msgstr "Phrase 1. It used to be like this"

#~ msgid "Эта фраза нигде не используется"
#~ msgstr "This phrase is not used anywhere"

Результат будет следующим:

Тестируем JavaScript

В этой части на примере двух наших тестов я коротко расскажу о том, как тестировать JS. Для более детальной информации рекомендую прочитать эту статью от learn.javascript.ru.

Начнём с создания файлов. Нам нужен scripts.js с тестируемыми функциями и tests.html, в котором будут находиться и запускаться тесты.

Для JS тестов у нас используется фреймворк mocha. Он позволяет выполнять тесты как в консоли, так и в браузере. Мы остановимся на втором варианте.

Тестируемые функции

Для этих тестов я взял максимально простые функции. Вот они:

function getFileExtension(file){
    return file.substr(file.lastIndexOf('.') + 1);
}

function isExtensionValid(extension){
    return extension !== 'exe' && extension !== 'i';
}

Первая выдаёт расширение файла. Вторая проверяет, валидно ли оно. Мы не принимаем файлы с расширением «i» либо «exe», поэтому такие файлы будут считаться невалидными. Обе функции используются для валидации формы фидбека.

Полный код tests.html

Полный код файла tests.html выглядит так:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">

        <title>JavaScript tests</title>

        <!-- Our scripts -->
        <script src="scripts.js"></script>

        <!-- Load Mocha and it's styles-->
        <script 
            src="https://cdnjs.cloudflare.com/ajax/libs/mocha/2.1.0/mocha.js">
        </script>
        <link rel="stylesheet"
            href="https://cdnjs.cloudflare.com/ajax/libs/mocha/2.1.0/mocha.css">

        <!-- Setup mocha-->
        <script>mocha.setup('bdd');</script>

        <!-- Load chai and add assert -->
        <script
            src="https://cdnjs.cloudflare.com/ajax/libs/chai/2.0.0/chai.js">
        </script>
        <script>const assert = chai.assert;</script>
    </head>

    <body>
        <script>
            // Constants for testing

            // For getFileExtension test
            const filesAndExtensions = {
                'file.doc': 'doc',
                'file.pdf': 'pdf',
                'test.i': 'i',
                'test.exe': 'exe',
            };

            // For isExtensionValid test
            const validExtensions = ['doc', 'docx', 'pdf', 'xls',];
            const invalidExtensions = ['i', 'exe'];

            // Tests

            describe("getFileExtension", function() {
                for (let [file, expected_extension] of
                Object.entries(filesAndExtensions)) {
                    it(`${file} has ${expected_extension} extension`,
                    function() {
                            assert.equal(
                            getFileExtension(file), expected_extension
                        );
                    });
                }
            });

            describe("isExtensionValid", function() {
                describe("The extension is not .i or .exe", function() {
                    validExtensions.forEach(extension => {
                        it(`${extension} is valid extension`, function() {
                            assert.isTrue(isExtensionValid(extension));
                        });
                    });
                });

                describe("The extension is .i or .exe", function() {
                    invalidExtensions.forEach(extension => {
                        it(`${extension} is invalid extension`, function() {
                            assert.isFalse(isExtensionValid(extension));
                        });
                    });
                });
            });

        </script>

        <!-- All tests results will be in this div -->
        <div id="mocha"></div>

        <script>mocha.run();</script>
    </body>
</html>

Разберём, что тут происходит.

Тег head

Начнём с тега head. В нём мы выполняем следующие действия:

  1. Подгружаем тестируемые функции;

  2. Подключаем mocha и её стили. Именно они будут использоваться для отображения тестов;

  3. Настраиваем mocha, указывая, какую методику тестирования мы будем использовать. BDD Позволяет наиболее полно описать, что делает тест. Обо всех методиках можно почитать тут;

  4. Подгружаем библиотеку chai. В ней находятся функции проверки тестов (shouldassert и expect). Хотя для BDD больше подходят should и expect, мы будем использовать знакомый нам из Python метод assert. Поэтому заносим его в константу.

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <title>JavaScript tests</title>

    <!-- (1) -->
    <!-- Our scripts -->
    <script src="scripts.js"></script>

    <!-- (2) -->
    <!-- Load Mocha and it's styles-->
    <script
        src="https://cdnjs.cloudflare.com/ajax/libs/mocha/2.1.0/mocha.js">
    </script>
    <link rel="stylesheet"
        href="https://cdnjs.cloudflare.com/ajax/libs/mocha/2.1.0/mocha.css">

    <!-- (3) -->
    <!-- Setup mocha-->
    <script>mocha.setup('bdd');</script>

    <!-- (4) -->
    <!-- Load chai and add assert -->
    <script
        src="https://cdnjs.cloudflare.com/ajax/libs/chai/2.0.0/chai.js">
    </script>
    <script>const assert = chai.assert;</script>
</head>

Константы для тестов

Перейдём к body и его тегу script. Первое, что мы делаем, — это создаём константы для тестов.

// Constants for testing

// For getFileExtension test
const filesAndExtensions = {
    'file.doc': 'doc',
    'file.pdf': 'pdf',
    'test.i': 'i',
    'test.exe': 'exe',
};

// For isExtensionValid test
const validExtensions = ['doc', 'docx', 'pdf', 'xls',];
const invalidExtensions = ['i', 'exe'];

Тесты функции getFileExtension

После констант идут тесты функции getFileExtension. Вот как они создаются:

  1. Создаём блок с названием getFileExtension.Функция describe позволяет отделять тесты друг от друга, вынося их в разные блоки. Т.е. мы можем разделить тесты для getFileExtension и для isExtensionValid. Тогда в браузере они будут отображаться порознь. Первый аргумент describe — это название блока. Оно может быть любым, поскольку используется только при отображении в браузере. Второй аргумент – функция, выполняющая тесты для данного блока;

  2. В цикле проходимся по элементам filesAndExtensions и получаем файл вместе с его ожидаемым расширением;

  3. В функцию it (она создаёт один тест) передаём описание теста и сам тест в виде функции;

  4. У assert-а вызываем метод equal. Его суть работы такая же как для assertEqual в Python. В данном случае мы сравниваем два расширения: полученное с помощью getFileExtension и ожидаемое.

describe("getFileExtension", function() { // (1)
    // (2)    
    for (let [file, expected_extension] of Object.entries(filesAndExtensions)) {
        it(`${file} has ${expected_extension} extension`, function() { // (3)
            assert.equal(getFileExtension(file), expected_extension); // (4)
        });
    }
});

Результат выполнения этих тестов будет таким:

В данном случае все 4 теста прошли успешно.

Тесты функции isExtensionValid

Теперь рассмотрим тесты для функции isExtensionValid. В них мы:

  1. Создаём describe с названием isExtensionValid;

  2. Разделяем наш describe ещё на два блока, поскольку у нас два разных типа тестов: для валидных и не валидных расширений. В первом функция isExtensionValid должна возвращать true, во втором – false. Поэтому будет разумно разграничить эти тесты. Благо describe позволяет создавать вложенные блоки;

  3. Проходимся по валидным расширениям и создаём для них тест, проверяющий что isExtensionValid возвращает true;

  4. Проходимся по не валидным расширениям, так же создаём для них тест, но теперь он проверяет, что isExtensionValid возвращает false.

describe("isExtensionValid", function() { // (1)
    describe("The extension is not .i or .exe", function() { // (2)
        // (3)
        validExtensions.forEach(extension => {
            it(`${extension} is valid extension`, function() {
                assert.isTrue(isExtensionValid(extension));
            });
        });
    });

    describe("The extension is .i or .exe", function() { // (2)
        // (4)
        invalidExtensions.forEach(extension => {
            it(`${extension} is invalid extension`, function() {
                assert.isFalse(isExtensionValid(extension));
            });
        });
    });
});

Результат выполнения этих тестов будет таким:

Запуск тестов

Все тесты будут отображаться в блоке div с id mocha. А для их запуска нужно вызвать команду run.

<!-- All tests results will be in this div -->
<div id="mocha"></div>

<script>mocha.run();</script>

Открыв tests.html в браузере, вы получите такую картину:

Помимо наших тестов справа вверху отображается панель. В ней можно увидеть:

  1. Число успешно выполненных тестов;

  2. Число упавших тестов;

  3. Время выполнения всех тестов;

  4. Сколько тестов (в процентах) уже успели прогнаться.

Запустить конкретный тест можно с помощью кнопки, находящейся справа от его названия.

Если нажать на название блока, например на getFileExtension, откроется результат выполнения тестов только этого блока. Также, если нажать на конкретный тест, можно увидеть команду его вызова.

В случае падения тестов, вы увидите, что именно пошло не так.

Заключение

Итак, сегодня мы разобрали тесты для JavaScript и написали проверку русского текста на английских страницах. Если вы ещё не читали первую часть, рекомендую это сделать. В ней много интересного и полезного. В следующей статье мы рассмотрим автозапуск тестов и некоторые полезные инструменты. Для фидбека или критики пишите в комментарии либо в мой инстаграм. Спасибо за внимание и до встречи в следующей статье)

Комментарии (2)


  1. XopHeT
    23.02.2022 09:38

    # А
    CODE_OF_FIRST_RUSSIAN_LETTER = 1040
    
    # я
    CODE_OF_LAST_RUSSIAN_LETTER = 1103
    
    CODES_OF_RUSSIAN_SYMBOLS = list(
        range(
            CODE_OF_FIRST_RUSSIAN_LETTER,
            CODE_OF_LAST_RUSSIAN_LETTER + 1
        )
    ) + [1025, 1105] # Ё, ё

    Замените числа на ord("A"), ord("я") и т.д.
    Так и от комментариев избавитесь и код читабельнее

    1. Обходит все английские страницы;

    2. Из каждой страницы извлекает весь контент (похожий пример был в прошлой статье);

    3. На нашем сайте большая часть текста, который видит пользователь, хранится в div с классом b-content. Поэтому тест извлекает контент из него с помощью метода find. Остальные блоки div мы тестируем отдельно;

    4. Получает из контента все слова;

    5. Проходится по каждому слову и по каждому символу;

    6. Проверяет, что символ не является русским.

    Зачем разбиваете на слова? Почему сразу по тексту не пробежаться?
    Думали ли регулярку составить на русские буквы и проверить на совпадение сразу со всем текстом?
    Если нужно выделить слова, содержащее русскую букву - регуляркой можно и такое сделать


    1. OsnovaDT Автор
      23.02.2022 11:45

      На счет ord("А") и ord("я") согласен, так лучше будет, спасибо.

      На слова разбивал, чтобы при выводе ошибки можно было понять в каком она слове

      Про регулярки не думал, попробую сделать, спасибо)