суббота, 4 августа 2012 г.

Тестирование ПО

Как я уже писал в предыдущей статье, меня порадовала концепция называемая "упреждающее программирование". Собственно, сегодня я предлагаю обсудить, как можно предупредить появление ошибок в используемом приложении при помощи тестирования. И хотя логика подсказывает, что без тестирования выпускать приложение в использование вообще нельзя, до сих пор есть команды, которые позволяют себе эту роскошь.
Тестирование бывает разное, вот Федор, пытается об этом рассказывать. Правда, он ленится и пишет достаточно редко. Но если коротко, то тестирование ПО - это процесс оценки приложения. Насколько правильно оно выполняет поставленные перед ним задачи.
Когда программист пишет код, зачастую, он уже знает как он будет его проверять. Вот если подать вот такие значения, то на выходе мы должны получить вот это. Зная  что он подаст на вход, он пишет код так, чтобы именно с этими данными все заработало. И, в большинстве случаев, оно действительно работает. Проблемы начинаются тогда, когда на вход метода подаются значения, которые разработчик не ожидал... Поэтому, сейчас появляются подходы вроде TDD, которые рекомендуют разработчику писать тесты не только для базового сценария (который он и так проверит), но и для всех остальных. Этот процесс будет называться верификацией, т.е. будет осуществляться проверка на правильность технической и/или функциональной корректности. К сожалению, даже для простых задач, проверить все возможные входные значения не представляется возможным. Одно из моих любимых заданий, это попросить человека написать метод для решения квадратного уравнения. Причем прошу написать метод так, чтобы он отрабатывал все возможные входные значения. Перед тем как читать дальше, попробуйте решить эту задачу самостоятельно.
Давайте начнем. Для данного рассмотрения, пусть сигнатура метода будет иметь вид:

public static void SolveQuadraticEquation(double a, double b, double c, out double[] roots)
a, b и c - коэффициенты квадратного уравнения, roots - массив корней уравнения.
В качестве первого приближения, можно реализовать наш метод следующим образом:
public static void SolveQuadraticEquation(double a, double b, double c, out double[] roots)
{
    double D = Math.Pow(b, 2) - 4 * a * c;
    roots = new double[] { (-b + Math.Sqrt(D)) / (2 * a), (-b - Math.Sqrt(D)) / (2 * a) };
}
Достаточно быстро в процессе его использования, мы наткнемся, что при некоторых входных значениях он работает не коректно. Надо тестировать. Вполне понятно, что проверять на всех возможных значениях a, b и c, это будет очень долго и муторно. Для того, чтобы проверить работу алгоритма, мы разобъем все возможные наборы входных значений на классы. В рамках одного класса значений алгоритм решения должен быть идентичным (должно совпадать количество корней и формула для их получения).
Давайте посмотрим на формулу решения квадратного уравнения через дискриминант:
Согласитесь, сразу становится видно, какие у нас классы решений (почему мнение о том, что классы решений здесь видны - ошибочно, я расскажу чуть позже).
Давайте напишем тесты, которые будут покрывать все три класса входных значений:
[TestMethod]
public void SolveQuadraticEquationDiscriminantGreaterThanZero()
{
    double a = 1;
    double b = -5;
    double c = 4;
    double[] exp = { 4, 1 };
    double[] act = null;
    MyMath.SolveQuadraticEquation(a, b, c, out act);
    CollectionAssert.AreEqual(exp, act);
}
[TestMethod]
public void SolveQuadraticEquationDiscriminantLessThanZero()
{
    double a = 1;
    double b = 4;
    double c = 5;
    double[] exp = new double[0];
    double[] act = null;
    MyMath.SolveQuadraticEquation(a, b, c, out act);
    CollectionAssert.AreEqual(exp, act);
}
[TestMethod]
public void SolveQuadraticEquationDiscriminantIsEqualToZero()
{
    double a = 1;
    double b = -4;
    double c = 4;
    double[] exp = { 2 };
    double[] act = null;
    MyMath.SolveQuadraticEquation(a, b, c, out act);
    CollectionAssert.AreEqual(exp, act);
}
Понятно, что вот такой код, будет проходить все эти тесты:
public static void SolveQuadraticEquation(double a, double b, double c, out double[] roots)
{
    double[] result = null;
    double D = Math.Pow(b, 2) - 4 * a * c;
    if (D > 0)
    {
        result = new double[] { (-b + Math.Sqrt(D)) / (2 * a), (-b - Math.Sqrt(D)) / (2 * a) };
    }
    else if (D == 0)
    {
        result = new double[] { -b / (2 * a) };
    }
    else
    {
        result = new double[0];
    }
    roots = result;
}
Все хорошо? Нет, на самом деле, если попытаться вызвать наш метод с определенными параметрами, то мы сможем увидеть вот такую картинку:
Как видите, посмотрев на формулы вычисления мы определили классы входных данных неправильно! Нет, если вы уверены, что ваш пользователь абсолютно точно знает что такое квадратное уравнение, и никогда не введет вырожденный случай 0*x^2+b*x+c=0, то вы можете дальше не читать. Ведь к программированию вы пока имеете опосредованное отношение. Ведь если есть возможность работать с вашей программой неправильно, то пользователь обязательно найдет эту возможность, причем в самый неподходящий момент.
Ок, давайте продолжим написание тестов:
[TestMethod]
public void SolveQuadraticEquationAIsEqualToZero()
{
    double a = 0;
    double b = 4;
    double c = -4;
    double[] exp = { 1 };
    double[] act = null;
    MyMath.SolveQuadraticEquation(a, b, c, out act);
    CollectionAssert.AreEqual(exp, act);
}
Мнительному параноику, такому как я, уже сейчас, должна прийти идея еще одного класса входных данных, когда у нас не только a, но и b равно 0:
[TestMethod]
public void SolveQuadraticEquationAAndBIsEqualToZero()
{
    double a = 0;
    double b = 0;
    double c = -4;
    double[] exp = new double[0];
    double[] act = null;
    MyMath.SolveQuadraticEquation(a, b, c, out act);
    CollectionAssert.AreEqual(exp, act);
}
Для того, чтобы проходить все перечисленные тесты, наш метод должен иметь вид:
public static void SolveQuadraticEquation(double a, double b, double c, out double[] roots)
{
    double[] result = null;
    if (a != 0)
    {
        double D = Math.Pow(b, 2) - 4 * a * c;
        if (D > 0)
        {
            result = new double[] { (-b + Math.Sqrt(D)) / (2 * a), (-b - Math.Sqrt(D)) / (2 * a) };
        }
        else if (D == 0)
        {
            result = new double[] { -b / (2 * a) };
        }
        else
        {
            result = new double[0];
        }
    }
    else
    {
        if (b != 0)
        {
            result = new double[] { -c / b };
        }
        else
        {
            result = new double[0];
        }
    }
    roots = result;
}
И вот только теперь, мы можем быть хоть немного уверены, что в данной задаче наш метод работает адекватно.
Согласитесь, тривиальная задача, которую школьник будет делать пару минут, вылилась в написание методы в 34 строки и тестов больше чем на 50 строк. Но, в реальном проекте, может быть оправдана и еще большая паранойя, особенно если этот реальный проект будет считать ваши или, что еще хуже, чужие деньги, а то и управлять самолетом со 150 пассажирами на борту.
Пара выводов:
1. Проверяйте входные значения, даже на те значения, котроые "здравомыслящий человек" никогда не введет.
2. При решении алгоритмических задач, не ленитесь, пишите тесты. Объем кода конечно растет, зато вы можете быть уверены, что хотя бы в тех сценариях, на которые вы рассчитываете все работает правильно.
Ну и небольшая картинка, которая подтверждает, что наш метод покрыт тестами во всех возможных сценариях:


P.s. Попробуйте в аналогичной логике написать решение квадратного уравнения, в случае, когда корни могут быть комплексными. Или, чтобы не продумать все от начала до конца, попробуйте написать метод определяющий решение системы двух линейных уравнений.

Комментариев нет:

Отправить комментарий