Skip to content

Instantly share code, notes, and snippets.

@egslava
Last active June 29, 2023 11:45
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save egslava/a18c5e02836023beb7442f214b08d71d to your computer and use it in GitHub Desktop.
Save egslava/a18c5e02836023beb7442f214b08d71d to your computer and use it in GitHub Desktop.
_1. Python — тестирование ввода с клавиатуры в алгоритмических задачах

Тестирование ввода с клавиатуры в задачках на алгоритмы

Когда я проверяю алгоритмы, написанные другими людьми, я, первым делом, пишу тесты. Так я знаю, что ничего не сломаю + лучше понимаю (попутно документируя) код. Я бы мог просто не показывать тесты, но, имхо, лучше о них знать, чем не знать, поэтому постараюсь объяснить.

На примере кода Ксюши. Допустим, нам скинули на ревью вот такой код, он отвечает за считывание матрицы в двумерный список k.

l = "not_end"
k = []
while True:
    l = input()
    if l != "end":
        k.append([int(i) for i in l.split()])
    else:
        break

(этот код нужно улучшить. В этом смысл код-ревью. Но улучшать его мы будем во второй части статьи. В этой же части мы будем писать тесты, чтобы точно знать, что наши "улучшения" ничего не сломали)

Пока что, скорее всего, не очень понятно, что он делает. Нужны примеры! Вот примеры входных данных из задания:

9 5 3
0 7 -1
-5 2 9
end

и

1
end

(не спрашивайте меня, почему там "end" в конце, ведь матрицу можно считать и без него. Это -- учебное задание и я беру его "как есть")

Теперь мы оформим примеры в виде тестов. Тесты будут выглядеть, примерно (реальный код -- в конце), так:

assert _input("""
     9 5 3
     0 7 -1
     -5 2 9
     end
""") == [
    [9, 5, 3],
    [0, 7, -1],
    [-5, 2, 9]
]

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

Итого, что нам нужно:

  1. Как-то симулировать ввод с клавиатуры. В примере выше мы просто передаём текстовую строку, но в самом коде мы используем input() для ввода с клавиатуры, а не работаем со строками.
  2. Оформить код в виде функции. Именно функции мы тестируем.
  3. Нужно знать/понимать точный результат выполнения функции. Тут с этим всё просто, поэтмоу я опущу эту часть.

Симуляция ввода с клавиатуры

Пример: человек вводит с клавиатуры матрицу 3х3:

9 5 3
0 7 -1
-5 2 9
end

Мы можем считать её так:

_9_5_3 = input()
_0_7__1 = input()
__5_2__9 = input()
matrix = [_9_5_3, _0_7__1, __5_2__9]

Ну или просто:

matrix = [input(), input(), input()]

Вообще input() в Питоне (скорее всего) является сокращением чуть более общей конструкции: stdin.readline(). Т.е. мы могли бы написать и вот так:

from sys import stdin

matrix = [
    stdin.readline(),
    stdin.readline(),
    stdin.readline(),
]

Получается чуть более громоздко, поэтому, поголовно, при обучении, и в мелких скриптах, используется input(). Но эта конструкция более гибкая, например, вместо stdin мы могли бы использовать какой-то другой объект. Например, файл или поток StringIO, созданный из любой произвольной строки. Пример:

from io import StringIO

str_in = StringIO("""9 5 3
0 7 -1
-5 2 9
end""")

matrix = [
    str_in.readline(),
    str_in.readline(),
    str_in.readline(),
]

(вас может напрячь отсутствие отступов в строке и адекватных переносов. Однако, если вы попробуете отформатировать "как положено", то код поломается. Не переживайте -- чуть ниже мы разберёмся с этим)

stdin -- это "поток ввода" с клавиатуры. Вообще, потоков ввода может быть много: файл, интернет-соединение, usb-устройство и т.д. Вы можете создавать собственные потоки ввода, если почитаете доки к Питону. Когда вы открываете файл, то вам возвращается именно поток ввода с файла. Довольно стандартная штука для Питона. Так вот, stdin -- это именно поток ввода с клавиатуры. У него, как и у файла, есть стандартные операции -- readline(), read() и т.д

Мы можем создавать собственные потоки ввода, например, мы могли бы сделать из строки поток. Так подумали создатели Питона и уже это сделали, подарив нам StringIO в примере выше.

Для красоты, я бы завернул пример выше в функцию, которая принимала бы в себя любой поток, но, по-умолчанию, работала именно во стандартным вводом ( клавиатурой):

from sys import stdin


def _read_mat_3x3(_in=stdin):
    return [
        _in.readline(),
        _in.readline(),
        _in.readline(),
    ]


# читаем с клавиатуры
_read_mat_3x3()

# а вот и версия для тестов:
_test_case_1 = """9 5 3
0 7 -1
-5 2 9
end"""

from io import StringIO

_read_mat_3x3(StringIO(_test_case_1))

# а, вот, и считывание с файла:
with open('mat3x3.txt') as file:
    _read_mat_3x3(file)

Оформляем в виде функции

Итак, со вводом разобрались, теперь нужно оформить код Ксюши в виде тестируемой функции. Было:

l = "not_end"
k = []
while True:
    l = input()
    if l != "end":
        k.append([int(i) for i in l.split()])
    else:
        break

Стало:

from sys import stdin  # 1. импортировали


def _input(_in=stdin):  # 2. добавили
    l = "not_end"
    k = []
    while True:
        l = _in.readline()  # 3. input() => readline()
        if l != "end":
            k.append([int(i) for i in l.split()])
        else:
            break

Поменяли всего три строки. Немного усилий. Зато, теперь, возможно написать тесты.

Пишем тесты

Первый тест. Входные данные беру напрямую из задания. Не спрашивайте меня, почему идёт "end" в конце. Это просто условие задачи и я тупо копирую входные данные:

from io import StringIO

assert _input(StringIO(
"""1
end""")) == [[1]]

Второй тест, берём чуть более сложный пример из условия задачи:

_test_input_2 = """9 5 3
0 7 -1
-5 2 9
end"""
answer2 = [
    [9, 5, 3],
    [0, 7, -1],
    [-5, 2, 9],
]

# проверяем, соответствует ли ожидаемый
# результат и реальному:
assert _input(StringIO(_test_input_2)) == answer2

Если условие не выполняется, то assert упадёт с ошибкой. В этом и есть тест.

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

Тесты готовы! Если вы запустите этот код, то он ничего не покажет и ничего не сделает. Возможно, вы даже не заметите, что он отработал. Зато, если вы поменяете тестовые данные на некорректные, то assert грохнется и покажет ошибку. Пример простого грохающегося теста:

assert _input(StringIO(
"""1
end""")) == [[9]]

И вот текст ошибки:

runfile('/Users/egslava/projs/vova_algos/cs/ksenia/_1.py', wdir='/Users/egslava/projs/vova_algos/cs/ksenia')
Traceback (most recent call last):
  File "/usr/local/lib/python3.9/site-packages/IPython/core/interactiveshell.py", line 3457, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-2-46b11f8e76bf>", line 1, in <module>
    runfile('/Users/egslava/projs/vova_algos/cs/ksenia/_1.py', wdir='/Users/egslava/projs/vova_algos/cs/ksenia')
  File "/Applications/PyCharm CE.app/Contents/plugins/python-ce/helpers/pydev/_pydev_bundle/pydev_umd.py", line 198, in runfile
    pydev_imports.execfile(filename, global_vars, local_vars)  # execute the script
  File "/Applications/PyCharm CE.app/Contents/plugins/python-ce/helpers/pydev/_pydev_imps/_pydev_execfile.py", line 18, in execfile
    exec(compile(contents+"\n", file, 'exec'), glob, loc)
  File "/Users/egslava/projs/vova_algos/cs/ksenia/_1.py", line 103, in <module>
    assert _input(StringIO(
AssertionError

Вначале идёт всякий буллшит, зато в последних трёх строчках всё довольно информативно:

  1. Нам говорят номер строчки с ошибкой (у меня это 103)
  2. Говорят, что упал assert

А в PyCharm'е, номер строки даже кликабельный, поэтому, кликнув мышкой на 103, мы сразу идём на тест, который валится.

Улучшаем читабельность тестов

dedent

Улучшение читабельности тестов -- постоянная работа. Как мы учимся писать код, так и постоянно учимся писать более читабельные тесты. Поэтому единого рецепта тут нет. Рассмотрим данный конкретный случай:

_test_input_2 = """9 5 3
0 7 -1
-5 2 9
end"""

Попробуем написать красивше:

from textwrap import dedent

_test_input_2 = dedent("""
   9 5 3
   0 7 -1
   -5 2 9
   end
""").strip()
  1. строка больше не "липнет" к левому краю. Добавили отступы. Однако, если мы просто вставим пробелы/отступы и т.д., то readline их считает. А это нам не нужно. Так что, перед использованием этой строчки, нужно эти отступы удалить. В Python'е, для таких целей уже есть стандартная функция dedent. Кроме того, в
  2. Добавили перенос строки после первого """ и после end. Они убираются при помощи strip()

\n вместо переноса строки

Вместо

"""
многострочной
строки 
в 
Python'е
"""

можно использовать и старый формат, который прижился во многих языках программирования. Все строки -- однострочные, а где нужно перенести строку, вставляем " символ переноса строки": \n. Можете попробовать:

print('1\n2\n3') выведет:

1
2
3

Это можно использовать в мелких тестах. Было:

assert _input(StringIO("""1
end""")) == [[1]]

Стало:

assert _input(StringIO("1\nend")) == [[1]]

Полный код и продолжение

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

Вот полная версия кода с падающим и не падающими тестами. Просто скопируйте его в свой ipython3/python3/pycharm и запустите.

from sys import stdin  # 1. импортировали


def _input(_in=stdin):  # 2. добавили
    l = "not_end"
    k = []
    while True:
        l = _in.readline()  # 3. input() => readline()
        if l != "end":
            k.append([int(i) for i in l.split()])
        else:
            break


from io import StringIO
from textwrap import dedent

# 1. test ok
assert _input(StringIO("""1
end""")) == [[1]]

# 2. test ok
_test_input_2 = dedent("""
    9 5 3
    0 7 -1
    -5 2 9
    end
""").strip()

answer2 = [
    [9, 5, 3],
    [0, 7, -1],
    [-5, 2, 9],
]

assert _input(StringIO(_test_input_2)) == answer2

# 3. test fail
assert _input(StringIO("1\nend")) == [[3]]

Ввод: упрощаем, документируем и тестируем одновременно

Делю заметку на две части: упрощение и doctest'ы.

1. Упрощение кода

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

from sys import stdin


# хотим упростить vvv
def _input(_in=stdin):
    l = "not_end"
    k = []
    while True:
        l = _in.readline()
        if l != "end":
            k.append([int(i) for i in l.split()])
        else:
            break
    return k
# ^^^^^^^^^^^^^^^^^^^^


# дальше просто тесты
from io import StringIO
from textwrap import dedent

assert _input(StringIO('1\nend')) == [[1]]

_test_input_2 = dedent("""
    9 5 3
    0 7 -1
    -5 2 9
    end
""").strip()

answer2 = [
    [9, 5, 3],
    [0, 7, -1],
    [-5, 2, 9],
]

assert _input(StringIO(_test_input_2)) == answer2

Тесты трону только в конце заметки, пока смотрим только на саму функцию:

l = "not_end"
k = []
while True:
    l = _in.readline()
    if l != "end":
        k.append([int(i) for i in l.split()])
    else:
        break
return k
  1. Значение "not_end" нигде не используется, поэтому, лучше, от него вообще избавиться как-нибудь. Заменив на None, не сломаем программу, зато читатель кода будет знать, что "not_end" -- это не какое-то особенное значение, которое нужно запомнить:
l = None
k = []
while True:
    l = _in.readline()
    if l != "end":
        k.append([int(i) for i in l.split()])
    else:
        break
return k
  1. Можно избавиться от while True + break, с помощью "моржового оператора" , который позволяет присвоить значение в переменную и сразу же что-то с ним сделать (сравнить на end):
k = []
while (l := _in.readline()) != 'end':
    k.append([int(i) for i in l.split()])
return k
  1. Потоки позволяют считывать данные не построчно, а сразу все строчки, с помощью readlines():
lines = _in.readlines()
del lines[-1]  # удаляем последнюю ("end") строчку

k = []
for l in lines:  # заменяем while на for
    k.append([int(i) for i in l.split()])
return k
  1. Можно использовать "распаковку", чтобы сделать код чуть красивее и избавиться от строчки с удалением:
# тут присваеваем все строчки в переменную lines,
# а последнюю строчку в переменную "_", т.е. 
# выбрасываем
*lines, _ = _in.readlines()

k = []
for l in lines:  # заменяем while на for
    k.append([int(i) for i in l.split()])
return k

Пример распаковки, если вы с ней не имели дел:

a, b, *numbers, c = ['a', 'b',  1, 2, 3, 'c']

В numbers попадёт всё, что не попало в другие переменные. Распаковывать можно все, что может "стать списком": list, tuple, генераторы, строки.
и т.д.

  1. В принципе, это выражение можно упростить, т.к. вместо цикла тут можно попробовать использовать list comprehension. Я не хочу давать ссылку на объяснение, что это такое, потому что объяснения обычно длинные. Мне кажется, что этот пример, сам по себе, должен объяснять, что такое list comprehension. Скажите мне, если он понятен или непонятен, пожалуйста:
*lines, _ = _in.readlines()
return [[int(i) for i in l.split()] for l in lines]
  1. Самый короткий код, обычно, -- не самый понятный. Тут я бы поиграл с названиями переменных, попытался избавиться от сложности вложенных list comprehension'ов и добавил комментов:
 *rows, _ = _in.readlines()  # игнорим "end" в конце
matrix = [
    [int(cell) for cell in row.split()]
    for row in rows
]
return matrix
  1. Я думал ещё и над более кратким вариантом:
return [
    [int(cell) for cell in row.split()]
    for row in _in.readlines()[:-1]
]

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

Doctests

Но я бы оставил вариант с кодом выше, если бы добавил документационных комментариев (а я бы добавил, наверное, если бы отдавал код кому-то на ревью):

def _input(_in=stdin):
    """
    Считывает матрицу из входного потока
    :return: вложенный список (2д-массив/матрица)

    Например:
    >>> _input(StringIO(dedent(('''
    ...     1 2
    ...     3 4
    ...     end
    ... ''').strip())))
    [[1, 2], [3, 4]]
    """
    return [
        [int(cell) for cell in row.split()]
        for row in _in.readlines()[:-1]
    ]


# Чтобы doctest'ы запускались при запуске файла, 
# добавляем в конец:
import doctest

doctest.testmod()

# по-умолчанию, многострочные строчки в docttest'ах
# "склеиваются" и получается одна длинная строка,
# поэтому добавляем спец. комментарий,
# чтобы Python знал, что не надо так делать:

# doctest: +NORMALIZE_WHITESPACE
  1. На мой взгляд, можно добавить чуть больше тестов:
def _input(_in=stdin):
    """
    Считывает матрицу из входного потока
    :return: вложенный список (2д-массив/матрица)

    Например:
    >>> _input(StringIO(dedent(('''
    ...     1 2
    ...     3 4
    ...     end
    ... ''').strip())))
    [[1, 2], [3, 4]]

    Не забывайте `end` в конце! Он всегда идёт
    последним, по условию задачи, иначе последняя 
    строка матрицы пропадёт:
    >>> _input(StringIO(dedent('''
    ...     1 2
    ...     3 4
    ... ''').strip()))
    [[1, 2]]

    Пустая строчка (даже без end) вернёт пустой
    список (1d), а не пустую матрицу (2d):
    >>> _input(StringIO(""))
    []
    """

    return [
        [int(cell) for cell in row.split()]
        for row in _in.readlines()[:-1]
    ]

Эта документация и объясняет, и гарантирует, что код не рухнет, если отсутствует end в конце или, если подадим пустую строчку.

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

def _example(s: str):
    return StringIO(dedent(s).strip())

Результат

Итого, полный код функции с тестами и документацией выглядит так:

from sys import stdin

def _input(_in=stdin):
    """
    Считывает матрицу из входного потока
    :return: вложенный список (2д-массив/матрица)

    Например:
    >>> _input(_example('''
    ...     1 2
    ...     3 4
    ...     end
    ... '''))
    [[1, 2], [3, 4]]

    Не забывайте `end` в конце! Он всегда идёт
    последним, по условию задачи:
    >>> _input(_example('''
    ...     1 2
    ...     3 4
    ... '''))
    [[1, 2]]

    Пустая строчка (даже без end) вернёт пустой
    список (1d), а не пустую матрицу (2d):
    >>> _input(_example(""))
    []
    """

    return [
        [int(cell) for cell in row.split()]
        for row in _in.readlines()[:-1]
    ]

# по-умолчанию, многострочные строчки в docttest'ах
# "склеиваются" и получается одна длинная строка,
# поэтому добавляем спец. комментарий,
# чтобы Python знал, что не надо так делать:

# doctest: +NORMALIZE_WHITESPACE

def _example(s: str):
    from io import StringIO
    from textwrap import dedent
    return StringIO(dedent(s).strip())

# Чтобы doctest'ы запускались при запуске файла,
# добавляем в конец:
import doctest
doctest.testmod()

По итогу, у нас получился файл почти такого же размера, но, на этот раз, с примерами/документацией, а не только тестами.

@JuliaBars
Copy link

Very usefull, thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment