Умолчания реализации как свойства черного ящика для тестировщика

05/16/2017

Владислав Шулькевич

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

Предметная область и некоторые её особенности
В качестве конкретной реализации рассмотрим реализацию языка программирования. Большинство реализаций языков имеют довольно подробные спецификации. Некоторые из них можно объять отдельно взятым мозгом целиком, другие – нет. Дело осложняется тем, что в подавляющем большинстве случаев тестировщик не станет искать причины того или иного поведения программы в спецификации. Кроме того, в зависимости от архитектуры языка может быть предусмотрено различное поведение, от выброса исключения до гробового молчания.
В качестве примера рассмотрим возможную глубину рекурсии по умолчанию в различных языках программирования. Там, где это уместно, будем использовать переполнение типа. Для тестовой функции выберем вычисление факториала. Рассматривать будем вычисление рекурсивное, наивное. Результатом теста будем считать найденную глубину рекурсии в различных языках и способ реагирования на возникшую ошибку.
Во всех примерах напишем функцию и клиентский код. В качестве первого примера рассмотрим Python, так как типизация у него динамическая, а размер результата ограничен только имеющейся памятью.

Python

def factorial(x):
    if x == 0:
        return 1
    else:
        return x * factorial(x-1)


def usage():
    print('\nUsage:')
    print('Enter something like "factorial N", where N is natural number...\n')


if __name__ == "__main__":
    from sys import argv
    if len(argv) != 2 or int(argv[1]) < 0:
        usage()
    else:
        print(factorial(int(argv[1])))


Запустим код на исполнение, задав для теста последовательно два числа – 5 и 945:




Этот тест наглядно демонстрирует исключительную пригодность Python для научных вычислений.
Но если в качестве аргумента указать 998, то получим следующее:



В какой-то момент глубина рекурсии по умолчанию будет превышена и интерпретатор выбросит исключение. Этот самый «момент» заранее не известен. Если функция используется где-то дальше в коде, такой код будет неработоспособным в некотором диапазоне значений аргумента.
Иным образом дело обстоит в языке Go (Golang).

Golang
Если все делать правильно и использовать большую арифметику, Go сможет подсчитать и вывести, например, факториал числа 100 000. При этом ошибка нарушения глубины рекурсии не наступает. Есть ли она там, глубина рекурсии по умолчанию?

package main

import (
   "os"
   "fmt"
   "math/big"
   "strconv"
)


func Usage() {
       fmt.Println("\nUsage:")
       fmt.Println("Enter something like factorial N, where N is natural number...")
}


func factorial(n int64) *big.Int {
   if n < 0 {
        Usage()
        os.Exit(1)
   }
   if (n==0) {
        return big.NewInt(1)
   }
   bigN := big.NewInt(n)
   return bigN.Mul(bigN, factorial(n-1))
}


func main() {
       if len(os.Args) < 2 {
              Usage()
              os.Exit(1)
       } else {
              if N, err := strconv.ParseInt(os.Args[1], 10, 64); err == nil {
              fmt.Println(factorial(N))
              }
       }
}


Запуск:


Результат:


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

package main

import (
       "os"
       "fmt"
       //"math/big"
       "strconv"
)


func Usage() {
       fmt.Println("\nUsage:")
       fmt.Println("Enter something like factorial N, where N is natural number...")
}


func factorial(n int64) int64 {
       if n < 0 {
              Usage()
              os.Exit(1)
       }
       if n == 0 {
              return 1
       }
       return n * factorial(n - 1)
}


func main() {
       if len(os.Args) < 2 {
              Usage()
              os.Exit(1)
       } else {
              if N, err := strconv.ParseInt(os.Args[1], 10, 64); err == nil {
                     fmt.Println(factorial(N))
              }
       }
}


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

Запуск:


Java
Пример на Java с использованием восьмибайтных чисел для хранения ответа будет вести себя так же, как пример на Go, что ожидаемо исходя из размера типа long.

class FactorialUtil {
    public static long fact(int num) {
        return (num == 0) ? 1 : num * fact(num - 1);
    }
}


public class FactorialDemo {
    public static void main(String[] args) {
        if (args.length != 0) {
            int N = Integer.parseInt(args[0]);

            if (N < 0) {
                Usage();
            } else {
                System.out.println(FactorialUtil.fact(N));
            }
        } else {
            Usage();
        }
    }

    private static void Usage() {
        System.out.println("\nUsage:");
        System.out.println("Enter something like factorial N, where N is natural number...");
    }
}


Запуск:


Java Language Specification пункт 5.1.3 "narrowing primitive conversion" говорит: «Despite the fact that overflow, underflow, or other loss of information may occur, narrowing conversions among primitive types never result in a run-time exception».
Существуют различные объяснения, почему это так. Но не всегда есть желание и возможность разбираться в причинах принятия тех или иных архитектурных решений.
Если переписать использованный пример, заменив long на BigInteger, то мы сможем посчитать и вывести факториал 10 000, но не сможем посчитать факториал 100 000 и не из-за превышения глубины рекурсии. Просто у потока закончится стек, который каждому потоку Java выделяется платформозависимым образом. Его величину можно изменить (иногда бывает нужно и уменьшить, не только увеличить) с помощью опций компилятора или специальными вставками непосредственно в коде.

class FactorialUtil {
    public static BigInteger fact(int n) {
        return n == 1 ? BigInteger.ONE : BigInteger.valueOf(n).multiply(fact(n-1));
    }
}


public class FactorialDemo {
    public static void main(String[] args) {
        if (args.length != 0) {
            int N = Integer.parseInt(args[0]);

            if (N < 0) {
                Usage();
            } else {
                System.out.println(FactorialUtil.fact(N));
            }
        } else {
            Usage();
        }
    }

    private static void Usage() {
        System.out.println("\nUsage:");
        System.out.println("Enter something like factorial N, where N is natural number...");
    }
}





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



В тематический каталог