company_banner

Property-based тестирование для JavaScript и UI: необычный подход к автоматизированным тестам

    Elon Musk's Tesla Roadster
    Falcon Heavy Demo Mission

    Писать тесты скучно. А то, что скучно делать, постоянно откладывается. Меня зовут Назим Гафаров, я разработчик интерфейсов в Mail.ru Cloud Solutions, и в этой статье покажу вам другой, немного странный подход к автоматизированному тестированию.

    Что не так с обычным тестированием и что делать


    Итак, представьте, что у вас есть такая функция суммирования:

    function sum (a, b) {
       return a + b
    }
    

    Все мы понимаем важность юнит-тестов. Давайте напишем тест на эту функцию:

    const {equal} = require('assert')
    
    const actual = sum(1, 2)
    const expected = 3
    
    equal(actual, expected)
    

    Передаем на вход 1 и 2, на выходе ожидаем 3. Все просто — это классическое юнит-тестирование на основе примеров, так называемый example-based testing. Тест работает, все довольны, можно катить в прод. Но тут в игру вступает ваш коллега — сказочный энтерпрайз-программист. Однажды ему понадобилась ваша функция суммирования, но по каким-то причинам он решил ее немного подправить:

    function sum (a, b) {
     return 3
    }
    

    В этом коде есть какая-то проблема, но с другой стороны — все тесты проходят, а TDD нас учит, что нужно писать минимальный код, который заставит ваши тесты проходить. Это справедливо. Преодолев свой подростковый гнев, вы пишете еще один тест — передаете 4 и 8, ожидаете 12:

    equal(
     sum(4, 8),
     12
    )
    

    Но энтерпрайз-программист не успокаивается. Он еще раз правит функцию суммирования так, чтобы тесты не падали:

    function sum (a, b) {
     if (a == 4 && b == 8) return 12
     return 3
    }
    

    Вы могли бы добавить в тестовый набор еще примеры, и так продолжалось бы до бесконечности. В этот момент вы думаете: «Зачем его только на работу взяли?», но деваться некуда. Вы выпускаете свое секретное оружие — рандом:

    const a = Math.random()
    const b = Math.random()
    const actual = sum(a, b)
    const expected = a + b
    
    equal(actual, expected)
    

    Теперь все хорошо, но огромная проблема такого теста — то, что вы реализацию функции дублируете в коде теста. То есть используете одну реализацию для тестирования другой:

    • теперь у вас есть две реализации одной и той же функции, которые нужно держать в актуальном состоянии;
    • очевидно, что функция суммирования довольно примитивна, а представьте, если ваш код делает что-то посложнее суммирования.

    Кроме того, когда мы так тестируем код, то понимаем, что это некоторое лукавство, ведь мы проверили его только на двух парах входных данных.

    equal( sum(1, 2), 3 )
    equal( sum(4, 8), 12 )
    

    Этот тест показывает, что код правильно отрабатывает только в этих двух случаях. Лучшие из нас догадываются, что неплохо протестировать граничные случаи, вроде отрицательных чисел, чисел с плавающей точкой и других. Но это все равно отражает предвзятость разработчика.

    Опять же возникает проблема с чертовым энтерпрайз-программистом (The Enterprise Developer From Hell). Этот термин ввел Скотт Влашин, известный популяризатор F#. Вы можете подумать, что энтерпрайз-программист нереалистичен. Понятно, что в здоровой компании ни один нормальный человек не будет ломать функции, но во многих случаях мы сами действуем таким образом.

    Мы ведь пишем функции намного сложнее, чем A+B, и в процессе реализации можем написать код, который работает в частных конкретных случаях, а не в общем. Это происходит не из-за злого умысла, а непреднамеренно, из-за неосознанности и слепоты.

    Итак, что мы можем сделать с этим. Давайте думать.

    A + B

    Нет смысла завязывать тесты на A или на B, нужно тестировать то, что посередине, сам плюсик. То есть нужно написать такой тест, который сфокусируется не на входе-выходе, а на свойствах. Эти свойства должны быть истинными для любой правильной реализации. Поэтому давайте подумаем, какие свойства есть у суммирования.

    Коммутативность


    По школьной программе мы знаем такое свойство: «от перемены мест слагаемых сумма не меняется». То есть сложение обладает свойством переместительности — коммутативностью. Давайте напишем тест, который проверит, что наша реализация соответствует этому свойству:

    const actual = sum(1, 2)
    const expected = sum(2, 1)
    
    equal(actual, expected)

    В этом тесте хорошо то, что он работает с любыми входными данными, а не только со специальными магическими числами. Ничего не мешает нам сделать что-то такое:

    const rand = Math.random
    const [n1, n2] = [ rand(), rand() ]
    
    const actual = sum( n1, n2 )
    const expected = sum( n2, n1 )
    
    equal(actual, expected)

    Сложение двух чисел — не то что мы делаем каждый день, однако подобный подход можно использовать не только для математических операций, но и для тестирования реальных веб-служб, баз данных и даже интерфейсов. Поэтому следующий пример с делением двух чисел:

    function div (dividend, divisor) {
     return dividend / divisor
    }

    Заходим в Википедию, и оказывается, что у деления есть свойство дистрибутивность справа. Оно означает, что деление суммы двух чисел на какой-то делитель — это то же самое, что деление их по отдельности. Отлично, давайте протестируем это:

    const [n1, n2, n3] = [rand(), rand(), rand()]
    
    const left = div(n1 + n2, n3)
    const right = div(n1, n3) + div(n2, n3)
    
    equal(left, right)

    Теперь запускаем этот тест в цикле много-много раз и при должном терпении получаем такую комбинацию входных данных:

    const [n1, n2, n3] = [0, 0, 0]

    И тест не проходит, потому что деление нуля на ноль дает NaN:

    assert.js:85
     throw new AssertionError(obj);
     ^
    
    AssertionError [ERR_ASSERTION]: NaN == NaN

    А NaN, как известно, не равен NaN. Это нормальное поведение JavaScript, но теперь мы понимаем, что в нашей функции деления нужно сделать проверку на нули. Крутим наш тест в цикле дальше, каждый раз генерируя новую порцию случайных данных. В какой-то момент получаем такую комбинацию:

    const [n1, n2, n3] = [2, 1, -347]

    И тест опять падает:

    assert.js:85
     throw new AssertionError(obj);
     ^
    
    AssertionError [ERR_ASSERTION]:
    -0.008645533141210375 == -0.008645533141210374

    Это неточность округления чисел с плавающей точкой. То есть опять нормальные ограничения вычислений, но когда мы генерируем входные данные, эти ограничения становятся явными. Теперь нам в нашей функции нужно подумать про явное округление или алгоритм, который уменьшит вычислительную погрешность, например алгоритм Кахана Бабушки.

    Казалось бы, мы написали обычный тест, но в нем нет магических значений, взятых из нашего воображения. Мы используем произвольные значения и получаем возможность выполнять тест много-много раз на разных входных данных, проверяя саму спецификацию, то есть то, что функция должна делать, а не ее поведение на единичных примерах.

    Это и есть тестирование на основе свойств — property-based testing. То есть комбинация следующих вещей:

    1. Сначала мы описываем входные данные — говорим системе, какие случайные данные нужно сгенерировать.
    2. Потом описываем ожидаемые свойства — какие-то условия прохождения теста.
    3. А потом просто запускаем этот тест много-много раз.

    Как выявлять свойства?


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

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

    $(\forall x\in X) P(x)$

    Конечно, никто не запрещает гонять тест в цикле, вручную подставляя эти иксы, но нам обязательно нужен надежный способ воспроизводить упавшие тесты. К счастью, для этого есть готовые фреймворки.

    Фреймворки


    Как известно, все лучшее в программировании было изначально придумано в мире Haskell. 20 лет назад идея property-тестирования была реализована во фреймворке QuickCheck.

    Сейчас эта форма тестирования в экосистеме Haskell фактически является доминирующей. Для JavaScript есть несколько библиотек, но я остановлюсь на двух: JSVerify и fast-check.

    const jsc = require('jsverify')
    
    jsc.assertForall(
     jsc.integer, jsc.integer,
     (a, b) => a + b === b + a
    )

    Это простой тест на переместительное свойство сложения, о котором мы говорили в начале. Так как у нас нетипизированный язык, надо как-то сообщить фреймворку, какие аргументы мы ожидаем. Тут мы говорим, что нам нужно два числа и последним аргументом передаем предикат. По умолчанию JSVerify запустит тест сто раз, каждый раз генерируя новую пару входных значений.

    Давайте проверим переместительное свойство у вычитания. Конечно, такого свойства у вычитания нет, поэтому мы получим объект с ошибкой и законсолим ее:

    const subtractionIsCommutative = jsc.checkForall(
     jsc.integer, jsc.integer,
     (a, b) => a - b === b - a
    )
    
    console.log(subtractionIsCommutative)
    
    {
     counterexample: [ 0, 1 ],
     tests: 1,
     shrinks: 4,
     rngState: '0e168f30eac572b94d'
    }

    Система говорит, что упала после первого же теста на контрпримере 0 и 1. RngState — это состояние генератора случайных чисел. В данном случае тестовые данные являются детерминировано случайными. Random number generator выводит для нас seed, который можно подсунуть в test runner, чтобы воспроизвести упавший кейс. Это удобно для отладки, помогает с воспроизводимостью в CI/CD.

    mocha test.js --jsverifyRngState 0e168f30eac572b94d

    JSVerify имеет небольшой DSL для типов, который позволяет немного сократить запись. Иногда это бывает удобно, например, когда нужны пользовательские типы, легче написать так:

    jsc.assert(jsc.forall(
     '{ name: asciinestring; age: nat }',
     (obj) => {
         console.log(obj) // { name: '9lfpy', age: 34 }
         return true
     }
    ))

    Чем так:

    jsc.record({
     name: jsc.asciinestring,
     age: jsc.nat,
    })

    Выбирайте удобный вам способ. Так мы можем генерировать любые собственные типы, например объекты, которые приходят нам с бэкенда. Если встроенных генераторов не хватает, вы легко можете написать собственный. Допустим, вам нужна не просто строка, а строка с email-адресом. Можно сгенерировать ее таким образом:

    const emailGenerator = jsc
     .asciinestring.generator
     .map(str => `${str}@example.com`)

    В реальной жизни


    Теперь давайте посмотрим, как это можно применять в реальной жизни. У библиотеки query-string шесть миллионов скачиваний в неделю. Этот пакет указан в зависимостях более чем четырех тысяч других пакетов.

    Query-string делает одну простую вещь — парсит URL-строку в объект и, наоборот, может из объекта сгенерировать URL или его часть:

    queryString.parseUrl('https://foo.bar?foo=bar')
    //=> {url: 'https://foo.bar', query: {foo: 'bar'}}
    
    queryString.stringify({b: 1, c: 2, a: 3})
    //=> 'b=1&c=2&a=3'

    Естественно, эта библиотека покрыта кучей классических example-based тестов. Суммарно 400 строк кода тестов.

    Но вы не можете учесть все варианты, сколько бы тестов ни написали. Вместо того чтобы выдумывать новые примеры, автор библиотеки fast-check написал один единственный тест, сконцентрированный на свойствах библиотеки:

    fastCheck.property(
     queryParamsArbitrary, optionsArbitrary,
     (object, options) => deepEqual(
       queryString.parse(queryString.stringify(object, options), options),
       object
     )
    )

    Query-string — классическая инверсия, то есть любой объект должен быть переведен в query-строку, а если спарсить эту строку, то должен получиться исходный объект.

    Как вы понимаете, он тут же словил баг.

    Этот же подход он применил для тестирования печально известной библиотеки left-pad и обнаружил баг со строками, которые содержат символы вне основной плоскости Юникод, например emoji.

    Но как ему удавалось так легко выводить свойства, которые находят баги? Все потому, что он знаком с популярными свойствами. Давайте тоже рассмотрим их.

    Инверсия


    Подход известен так же, как Бильбо-тестирование в честь повести Толкиена «Туда и обратно».

    Допустим, у нас есть функция шифрования. Если к полученному результату применить дешифрование, в итоге мы должны получить исходное сообщение:

    const string = 'ANY_STRING'
    const encrypted = encrypt(string)
    
    expect( decrypt(encrypted) ).toBe( string )

    При этом не важно, какое именно сообщение мы зашифровали — это может быть любая строка. Соответственно, мы можем ее сгенерировать. То же самое можем применить для сериализации-десериализации, кодирования-декодирования, сжатия без потерь и так далее.

    Именно это свойство мы видели при тестировании query-string:

    const obj = {any: 'object'}
    
    _.isEqual(
       JSON.parse( JSON.stringify(obj) ),
       obj,
    )

    Запись/чтение, вставка/поиск также соответствуют этому шаблону, даже если они не являются строгими инверсиями.

    Обратимость


    Также частным случаем инверсии является round-trip. Это когда мы берем обратимую функцию и применяем ее дважды:

    _.isEqual(
     [...array].reverse().reverse(),
     array,
    )

    То есть если наша функция переворачивает массив, мы можем сгенерировать массив с любыми данными внутри, перевернуть его дважды и получившийся результат должен соответствовать исходному массиву.

    Инвариантность


    Поиск инвариантов — это поиск чего-то, что не меняется при применении функции. Допустим, у нас есть функция сортировки. Если применить сортировку к любому массиву, длина этого массива не должна поменяться:

    equal(
     [...array].sort().length,
     array.length,
    )

    Кроме того, элементы массива тоже не должны меняться: они могут поменять свой порядок, но сами по себе не меняются — каждый элемент исходного массива должен быть найден в отсортированном массиве.

    Размер, длина, содержимое — отличные характеристики для проверки того, что ваша функция не меняет то, что не должна менять. Обычно такая проверка недостаточна сама по себе, но часто действуют как контрпроверка к другим свойствам.

    Идемпотентность


    Например, к свойству идемпотентности. Это свойство при повторном применении функции будет давать тот же результат, что и при первом. Например, если мы единожды отсортировали массив, повторная сортировка ничего не изменит:

    _.isEqual(
     [...array].sort().sort(),
     array.sort(),
    )

    string.padStart(10) === string.padStart(10).padStart(10)

    Форматирование, поиск уникальных значений, нормализация, добавление существующего элемента в множество — все эти операции при повторном применении не должны ничего менять.

    Трудно доказать, легко проверить


    Это свойство обычно иллюстрируют на примере лабиринта. Найти выход из лабиринта трудно, но если ваша функция умеет находить выход, ее легко протестировать — нужно просто следовать ее указаниям. В результате она должна нас привести к выходу. Мы не знаем кратчайший это путь или нет, но хотя бы уверены — это правильный путь, который ведет к выходу.

    Другой пример — опять же функция сортировки. Правильно отсортировать массив довольно трудно. Но мы легко можем проверить результат сортировки — нужно последовательно брать элементы массива и сравнивать их с последующими. Если мы сортируем по возрастанию, то текущий элемент всегда должен быть меньше последующего (или равен ему).

    Эталонная реализация


    Также этот подход называют тестовым оракулом. Допустим, у нас есть две функции, которые делают одно и то же.

    Например, нам пришло в голову реализовать собственную функцию сортировки. При этом у нас есть встроенная в JavaScript функция сортировки, которую мы принимаем за эталонную. Идея тестового оракула в том, чтобы сравнивать результаты нашей сортировки с результатами эталонной.

    Таким образом, мы можем генерировать рандомные данные, подавать их в обе функции и проверять, что результаты совпадают:

    _.isEqual(
     [...array].sort(),
     fastestSortingAlgorithm(array),
    )

    Это хороший шаблон, если вы рефакторите старый код. Вы можете убедиться, что новая реализации выдает такие же результаты, как ее старый аналог. Тестовый оракул в данном случае проверяет свойство соответствия эталонной реализации.

    Только не падай


    В данном случае свойством выступает то, что функция хотя бы не падает. Такой способ тестирования называют фаззингом, когда мы передаем на вход приложению неправильные или случайные данные и смотрим, падает ли система, нет ли зависаний, нарушений внутренней логики и неожиданного поведения.

    Допустим, у нас есть API — неважно, какие ручки мы дергаем, какие данные передаем, в любом случае сервер не должен отвечать 500. Само по себе это свойство имеет немного смысла, но как отправная точка — сгодится.

    Тестирование UI


    Представьте, что у вас интернет-магазин с корзиной товаров, вам надо ее протестировать. Сначала нужно определить доступные действия:

    • мы можем добавить товар в корзину;
    • удалить товар;
    • очистить корзину.

    Теперь давайте выявлять свойства. Навскидку можно сказать, что количество товаров не может быть отрицательной величиной: Корзина >= 0. При этом в корзине не может быть товаров больше, чем в каталоге: Корзина <= Каталог. А сумма всей корзины не может быть меньше, чем цена самого дорогого товара в ней: sum(Корзина) >= max(Товары).

    Дальше вы в цикле много-много раз генерируете допустимую последовательность действий и проверяете все свойства после каждого шага. Условно говоря, вы в рандомном порядке много-много раз что-то добавляете в корзину, что-то удаляете, очищаете ее и так далее. И после каждого шага проверяете истинность всех свойств.

    При таком тестировании вы можете проходить через пользовательский интерфейс и нажимать на кнопочки как в обычных e2e-тестах. Но если у вас на фронте фреймворк, в котором представление — это чистая функция от модели, то вы можете просто манипулировать моделью.

    Такой подход применили разработчики Spotify для тестирования плейлиста.

    Плюсы и минусы тестирования на основе свойств


    Плюсы


    1. Тесты на основе свойств заменяют множество example-based тестов, то есть вы пишете меньше кода, а тестов получаете намного больше.
    2. Такие тесты могут сами находить крайние случаи, о которых вы могли не подумать: деление на ноль, строки с emoji и тому подобное.
    3. Их легче поддерживать, потому что у вас нет жестко закодированных данных в тестовом наборе — каких-то магических строк и чисел, непонятно откуда взявшихся. Такие тесты являются более общими, поэтому менее хрупкими.
    4. Писать такие тесты намного интереснее, чем традиционные, так как придумывать примеры скучно, а здесь за вас это делает библиотека.
    5. Тесты на основе свойств заставляют вас думать. Если вы лучше понимаете бизнес-требования, это заставляет вас иметь чистый дизайн и в тестах, и в коде.

    Минусы


    1. Написание каждого теста требует больше усилий. Нужно подумать о требованиях и вывести свойства.
    2. Классические тесты служат документацией к коду, то есть показывают пример использования ваших функций. Property-based тесты получаются более абстрактными, а значит, сложными для понимания.
    3. Каждый тест нужно запустить сотню раз, поэтому немного увеличивается время выполнения тестов.
    4. Такие тесты дают ложное ощущение безопасности. Допустим, вы выявили несколько свойств у функции и это дает вам уверенность, что реализация правильная. Однако свойство может быть необходимым, но недостаточным. Например, функция умножения обладает свойством переместительности точно так же, как суммирование, но делают эти функции немного разные вещи.

    Выводы


    Мы не должны отказываться от классических тестов, но можем их комбинировать с тестированием на основе свойств.

    Например, можно базовый функционал покрывать классическими тестами на основе примеров, а критически важные функции дополнительно покрывать property-тестами.

    P.S.


    Это текстовая версия доклада с HolyJS Piter 2019 и Panda Meetup #22.



    Что еще почитать:

    Mail.ru Group
    Строим Интернет

    Похожие публикации

    Комментарии 27

      0
      постойте, return 3 всё ещё успешно проходит equal(sum(1, 2), sum(2, 1))
        0

        Ну как так-то, 2 строчками ниже:


        const rand = Math.random
        const [n1, n2] = [ rand(), rand() ]
        
        const actual = sum( n1, n2 )
        const expected = sum( n2, n1 )
        
        equal(actual, expected)
          +5
          Вы не поверите, но и такой тест return 3 тоже проходит :D
            0

            Wait a second...


            Признаю, был неправ :(

              0
              Поэтому имеет смысл комбинировать example и property-based тесты.
                +3
                Могу ошибаться, но я бы добавил также свойство add x 0 = x. В таком случае просто return 3 (или любой другой константы) не сработает.
            +2

            Какова вероятность, что три рандома подряд выдадут вам 3 нуля? Для 64-битного числа это будет 1/2**63**3 = 1/7e56. Если взять суперкомпьютер ближайшего будущего на 7 эксафлопс, то матожидание выпадения джекпота из [ 0 , 0 , 0 ] будет 1e38 секунд или 1e30 лет.


            Но тут даже не настоящий рандом, а псевдорандом, где следующее значение является функцией от предыдущего, а это значит, что выдаёт он два одинаковых значения подряд примерно никогда.

              +2

              Я могу ошибаться, но думаю что либы по property-test-ы к случайным значениям ещё и явно примешивают всякие базовые пограничные значения. Скажем эмоджи, какие-нибудь особо хитрые utf8 штуки (вроде буквы Ё в mac os), нули, предельные числа, дроби и пр.

                0
                Да, хорошие реализации QuickCheck используют тот факт, что некоторые числа и строки гораздо чаще вызывают проблемы.
                  0

                  То есть некоторого предустановленного набора граничных условий хватит всем?


                  function( a , b ) {
                      return 1 / ( a * b - 42 )
                  }

                  Через сколько итераций QuickCheck найдёт проблемные значения?

                    0

                    Вероятно не найдёт вообще. Но вроде как никто и не позиционирует это как серебрянную пулю. Все особые случаи, которые автор теста сам смог увидеть/заметить/вспомнить/понять, надо записать явным традиционным образом, а не надеяться на то, что random всё выведет.

                      0

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

                        0
                        В порядке мозгоштурма. А если попробовать покрутить алгоритм подбора значений? Пока я услышал всего три стратегии такого алгоритма: прямой перебор, предустановленные спец значения и рандом. Наверняка же можно что-то тут улучшить и оставить алгоритм более или менее общим? Ну там, составить ast-дерево того, что мы тестируем, вычленить оттуда числа, пораскладывать эти числа на множители, вычленить арифметические знаки, поприменять обратные им операции к данным и прочее. Ну или выбрать некоторый набор признаков и пообучать на хорошо покрытом тестами коде нейронную сеть, которая будет подбирать аргументы?
                          0

                          Есть подобное, правда не совсем для property based testing, но близко — guided fuzzing, довольно популярный инструмент из этой категории — AFL. Изначально делался под C/C++, но его можно прикрутить и к другим языкам, например к питону (вот тут в третьей части это довольно неплохо описывается — а первые две посвящены как раз property based testing и генераторам тестовых данных для них, так что тоже может быть интересно)

                            0

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

                        0
                        А какая альтернатива? Ждать 1e30 лет пока пользователи найдут этот баг?
                          0

                          Открыть ящик, выписать классы эквивалентности, и на все граничные условия написать тесты, а не пытаться брутфорсить континуум.

                  0
                  хорошо пишешь
                    0
                    Чудесная тема, а есть что-то еще про выявление свойств?
                      0

                      Например вот: https://habr.com/ru/post/434008/
                      Ну и на самом деле я бы сказал первоисточник подобных статей: https://fsharpforfunandprofit.com/posts/property-based-testing-2

                        0
                        Да, имеет смысл обратиться к другим экосистемам, в которых такое тестирование уже давно практикуется. Haskell, F#, Scala и т.д.
                        Ничего нового тут не придумать, учитывая то, что они начали практиковать property-тесты намного раньше.
                        0

                        Мне казалось тесты должны удовлетворять условии детерминированности, чтобы можно было определить условие при котором возникает ошибка и заново его воспроизвести, то есть в них не должно быть date или random историй

                          +4

                          Вы правы. Поэтому генератор случайных чисел должен быть псевдо-случайным.

                            0
                            Я думаю, проблема детерминированности решается если рандомные значения логгируются, и вообще, используются для задания начальных параметров, а не внутри теста.
                            0
                            Спасибо! Хотелось бы что бы тема развивалась в индустрии.

                            P.S. Ссылки на видео с докладов лучше приложить в самое начало.
                              0

                              property-based testing это отлично если вы до конца понимаете property


                              Допустим, для xpath запроса точка в выражении 'fileName.txt' это не признак оси, а разделитель имени файла, но тесты иногда думают иначе:) Пример взят из реальной жизни. Понятно, что это ошибка составителя теста. Он не учёл такой вариант.

                                0

                                XPath ничего не знает про файлы и воспринимает fileName.txt как имя xml-тега.

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

                              Самое читаемое