Несмотря на большую популярность библиотеки testcontainers
в мире java, информации в сети по её применению в python практически нет. Даная статья - попытка ликвидировать этот пробел. Я не буду подробно рассказывать про pytest и testcontainers, что это такое можно почитать в интернете, я просто покажу пример того, как можно собрать это воедино.
В качестве БД будем использовать PostgreSQL. В сети есть следующий пример использования testcontainers с PostgreSQL:
with PostgresContainer("postgres:9.5") as postgres:
e = sqlalchemy.create_engine(postgres.get_connection_url())
result = e.execute("select version()")
Да, не много. Поэтому давайте разовьём этот пример до применения в реальном приложении.
Структура проекта
В качестве примера, создадим часть приложения, которое будет оперировать данными в двух БД и протестируем его. В нашем примере мы реализуем три метода бизнес-логики, которые будут работать с БД, напишем классы и методы для взаимодействия БД, напишем сами тесты и всё то, что потребуется для подготовки и запуска тестовой среды. Структура проекта выглядит следующим образом:
Небольшие пояснения к структуре:
db_services - пакет, в котором располагаются базовые процедуры для работы с БД
processing - пакет, содержащий процедуры реализующие бизнес-логику работы приложения
-
tests - пакет, содержащий всё необходимо для создания контейнера тестового SQL - сервера и наполнения его данными, а также сами тесты.
За основу баз данных возьмём кусочек от демонстрационной базы данных от PostgresPro и реализуем следующие базы:
Модули приложения
Дабы не перегружать пост кодом, уберу модули приложения под спойлер.
Модули приложения
engine_factory.py
В модуле реализована фабрика соединений ко всем БД приложения, по паттерну singleton. Такой подход позволяет использовать одни и те же соединения к БД из любых модулей приложения, без необходимости выполнения затратных операций в виде создания новых соединений.
import sqlalchemy.engine
from sqlalchemy import create_engine
class MetaSingleton(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super(MetaSingleton, cls).__call__(*args, **kwargs)
return cls._instances[cls]
class EngineFactory(metaclass=MetaSingleton):
connections, db_urls = ({},) * 2
user, passw, stand, db_name = (None,) * 4
def get_engine(self, db_name, schema_name=None) -> sqlalchemy.engine.Engine:
self.db_name = db_name
if None in (self.user, self.stand, self.db_name):
raise ValueError('Не заданы обязательные параметры: stand, user, db_name')
if self.connections.get((db_name, schema_name)):
return self.connections.get((db_name, schema_name))
else:
url = self.get_postgres_url(db_name)
if schema_name:
self.connections[(db_name, schema_name)] = create_engine(url, echo=False, echo_pool=False,
connect_args={
'options': f'-csearch_path={schema_name}'})
else:
self.connections[(db_name, schema_name)] = create_engine(url, echo=False, echo_pool=False)
return self.connections[(db_name, schema_name)]
def dispose_engines(self) -> None:
for engine in self.connections.values():
engine.dispose()
self.connections = {}
def add_db(self, base_name, url):
self.db_urls[base_name] = url
def get_postgres_url(self, base_name) -> str:
stand = self.stand.lower()
if stand == 'localhost':
return self.db_urls.get(base_name) if self.db_urls.get(base_name) else ValueError(
f'''URL для параметров stand={stand}, db_name='{base_name}' не найден ''')
db_service.py
Здесь напишем два основных метода взаимодействия с БД (GET и POST), которые упростят обращение к БД через стандартные SQL-запросы из других модулей.
from .engine_factory import EngineFactory
engine = EngineFactory()
def get_from_postgres(sql, db_name, schema_name=None) -> list:
result = []
pg_engine = engine.get_engine(db_name=db_name, schema_name=schema_name)
try:
with pg_engine.connect() as connection:
cursor = connection.execution_options(stream_result=True).execute(sql)
for row in cursor:
result.append(list(row))
except Exception as e:
raise RuntimeError(f'Ошибка при обращении к БД: {e}')
return result
def post_to_postgres(sql, db_name, schema_name=None) -> int:
pg_engine = engine.get_engine(db_name=db_name, schema_name=schema_name)
rows_processed = 0
try:
with pg_engine.connect() as connection:
cursor = connection.execution_options(stream_result=True, isolation_level='AUTOCOMMIT').execute(sql)
rows_processed = cursor.rowcount
cursor.close()
except Exception as e:
raise RuntimeError(f'Ошибка при выполнении операции {sql} в БД: {e}')
return rows_processed
airline.py
В данном модуле опишем методы, реализующие бизнес-логику в БД Airline
from db_services.db_service import get_from_postgres
from db_services.db_service import post_to_postgres
DB_NAME = 'airline'
# Установить статуса рейса
def set_flight_status(flight_id, status) -> int:
sql = '''
update airline.flights
set status = '%s'
where flight_id = %d
''' % (status, flight_id)
try:
return post_to_postgres(sql=sql, db_name=DB_NAME)
except Exception as e:
raise RuntimeError(e)
# Получить статус рейса
def get_flight_status(flight_id) -> list:
sql = '''
select status
from airline.flights
where flight_id = %d
''' % flight_id
try:
return get_from_postgres(sql=sql, db_name=DB_NAME)
except Exception as e:
raise RuntimeError(e)
bookings.py
В данном модуле опишем методы, реализующие бизнес-логику в БД Bookings
from db_services.db_service import get_from_postgres
DB_NAME = 'bookings'
# Получить список пассажиров, траты которых более limit
def get_premium_psg_list(limit) -> list:
sql = '''
select passenger_name, sum(amount)
from bookings.tickets
join bookings.ticket_flights using (ticket_id)
group by 1
having sum(amount) > %d ''' % limit
try:
return get_from_postgres(sql=sql, db_name=DB_NAME)
except Exception as e:
raise RuntimeError(e)
Тестовые БД
Очевидно, что создаваемая тестовая БД должна отражать структуру продуктовой БД, по крайней мере она должна содержать тестируемые объекты. Для создания тестовых БД, будем использовать ORM с декларативным определением классов.
Структура и тестовые данные для БД Airline
airline_db.py
from sqlalchemy import Column, String, INTEGER, TEXT, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class Aircrafts(Base):
__tablename__ = 'aircrafts'
__table_args__ = {'schema': 'airline'}
aircraft_code = Column(String(3), nullable=False, primary_key=True, comment='Код самолета, IATA')
model = Column(TEXT, nullable=False, comment='Модель самолета')
range = Column(INTEGER, nullable=False, comment='Максимальная дальность полета, км')
class Flights(Base):
__tablename__ = 'flights'
__table_args__ = {'schema': 'airline'}
flight_id = Column(INTEGER, nullable=False, primary_key=True, comment='Идентификатор рейса')
flight_no = Column(String(10), nullable=False, comment='Номер рейса')
aircraft_code = Column(String(3), ForeignKey('airline.aircrafts.aircraft_code'), nullable=False,
comment='Код самолета, IATA')
status = Column(String(20), nullable=False, comment='Статус рейса')
AIRCRAFTS_ROWS = [
{
"aircraft_code": "773",
"model": "Boeing 777-300",
"range": 11100
},
{
"aircraft_code": "763",
"model": "Boeing 767-300",
"range": 7900
},
{
"aircraft_code": "SU9",
"model": "Sukhoi Superjet-100",
"range": 3000
},
{
"aircraft_code": "320",
"model": "Airbus A320-200",
"range": 5700
},
{
"aircraft_code": "321",
"model": "Airbus A321-200",
"range": 5600
},
{
"aircraft_code": "319",
"model": "Airbus A319-100",
"range": 6700
},
{
"aircraft_code": "733",
"model": "Boeing 737-300",
"range": 4200
},
{
"aircraft_code": "CN1",
"model": "Cessna 208 Caravan",
"range": 1200
},
{
"aircraft_code": "CR2",
"model": "Bombardier CRJ-200",
"range": 2700
}
]
FLIGHTS_ROWS = [
{
"flight_id": 32959,
"flight_no": "PG0550",
"aircraft_code": "CR2",
"status": "On Time"
},
{
"flight_id": 28948,
"flight_no": "PG0242",
"aircraft_code": "SU9",
"status": "Arrived"
},
{
"flight_id": 33116,
"flight_no": "PG0063",
"aircraft_code": "CR2",
"status": "On Time"
},
{
"flight_id": 33117,
"flight_no": "PG0063",
"aircraft_code": "CR2",
"status": "Arrived"
},
{
"flight_id": 33111,
"flight_no": "PG0063",
"aircraft_code": "CR2",
"status": "Scheduled"
},
{
"flight_id": 28929,
"flight_no": "PG0242",
"aircraft_code": "SU9",
"status": "Arrived"
},
{
"flight_id": 33052,
"flight_no": "PG0359",
"aircraft_code": "CR2",
"status": "Cancelled"
},
{
"flight_id": 33043,
"flight_no": "PG0359",
"aircraft_code": "CR2",
"status": "On Time"
},
{
"flight_id": 33118,
"flight_no": "PG0063",
"aircraft_code": "CR2",
"status": "Arrived"
},
{
"flight_id": 30007,
"flight_no": "PG0386",
"aircraft_code": "SU9",
"status": "Delayed"
},
{
"flight_id": 28913,
"flight_no": "PG0242",
"aircraft_code": "SU9",
"status": "Arrived"
},
{
"flight_id": 33099,
"flight_no": "PG0063",
"aircraft_code": "CR2",
"status": "Cancelled"
},
{
"flight_id": 32207,
"flight_no": "PG0425",
"aircraft_code": "CN1",
"status": "Departed"
},
{
"flight_id": 33115,
"flight_no": "PG0063",
"aircraft_code": "CR2",
"status": "Arrived"
},
{
"flight_id": 33107,
"flight_no": "PG0063",
"aircraft_code": "CR2",
"status": "Scheduled"
},
{
"flight_id": 32806,
"flight_no": "PG0080",
"aircraft_code": "CN1",
"status": "Cancelled"
},
{
"flight_id": 32961,
"flight_no": "PG0550",
"aircraft_code": "CR2",
"status": "Cancelled"
},
{
"flight_id": 31611,
"flight_no": "PG0494",
"aircraft_code": "CN1",
"status": "Delayed"
},
{
"flight_id": 28895,
"flight_no": "PG0242",
"aircraft_code": "SU9",
"status": "Arrived"
},
{
"flight_id": 30961,
"flight_no": "PG0004",
"aircraft_code": "CR2",
"status": "Delayed"
},
{
"flight_id": 31946,
"flight_no": "PG0193",
"aircraft_code": "CN1",
"status": "Departed"
},
{
"flight_id": 28904,
"flight_no": "PG0242",
"aircraft_code": "SU9",
"status": "Arrived"
},
{
"flight_id": 28915,
"flight_no": "PG0242",
"aircraft_code": "SU9",
"status": "Arrived"
},
{
"flight_id": 33114,
"flight_no": "PG0063",
"aircraft_code": "CR2",
"status": "Arrived"
},
{
"flight_id": 33119,
"flight_no": "PG0063",
"aircraft_code": "CR2",
"status": "Scheduled"
},
{
"flight_id": 32863,
"flight_no": "PG0080",
"aircraft_code": "CN1",
"status": "On Time"
},
{
"flight_id": 33112,
"flight_no": "PG0063",
"aircraft_code": "CR2",
"status": "Scheduled"
},
{
"flight_id": 32898,
"flight_no": "PG0147",
"aircraft_code": "SU9",
"status": "On Time"
},
{
"flight_id": 28939,
"flight_no": "PG0242",
"aircraft_code": "SU9",
"status": "Arrived"
},
{
"flight_id": 33121,
"flight_no": "PG0063",
"aircraft_code": "CR2",
"status": "Scheduled"
},
{
"flight_id": 31363,
"flight_no": "PG0619",
"aircraft_code": "CN1",
"status": "Delayed"
},
{
"flight_id": 32083,
"flight_no": "PG0708",
"aircraft_code": "733",
"status": "Departed"
},
{
"flight_id": 28935,
"flight_no": "PG0242",
"aircraft_code": "SU9",
"status": "Arrived"
},
{
"flight_id": 28942,
"flight_no": "PG0242",
"aircraft_code": "SU9",
"status": "Arrived"
},
{
"flight_id": 31867,
"flight_no": "PG0304",
"aircraft_code": "SU9",
"status": "Departed"
},
{
"flight_id": 28912,
"flight_no": "PG0242",
"aircraft_code": "SU9",
"status": "Arrived"
},
{
"flight_id": 32871,
"flight_no": "PG0616",
"aircraft_code": "SU9",
"status": "Cancelled"
},
{
"flight_id": 32937,
"flight_no": "PG0147",
"aircraft_code": "SU9",
"status": "Departed"
},
{
"flight_id": 33120,
"flight_no": "PG0063",
"aircraft_code": "CR2",
"status": "Arrived"
},
{
"flight_id": 32247,
"flight_no": "PG0604",
"aircraft_code": "CR2",
"status": "Delayed"
}
]
AIRLINE_ROWS = {
Aircrafts: AIRCRAFTS_ROWS,
Flights: FLIGHTS_ROWS
}
Структура и тестовые данные для БД Bookings
bookings_db.py
from sqlalchemy import Column, String, INTEGER, BIGINT, TEXT, ForeignKey, PrimaryKeyConstraint, NUMERIC
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class Ticket(Base):
__tablename__ = 'tickets'
__table_args__ = {'schema': 'bookings'}
ticket_id = Column(BIGINT, nullable=False, unique=True, autoincrement=True, primary_key=True,
comment='Номер билета')
passenger_id = Column(String(20), nullable=False, comment='Идентификатор пассажира')
passenger_name = Column(TEXT, nullable=False, comment='Имя пассажира')
class Ticket_Flights(Base):
__tablename__ = 'ticket_flights'
__table_args__ = {'schema': 'bookings'}
ticket_id = Column(BIGINT, ForeignKey('bookings.tickets.ticket_id'), nullable=False, unique=True,
comment='Номер билета')
flight_id = Column(INTEGER, nullable=False, comment='Идентификатор рейса')
amount = Column(NUMERIC(10, 2), nullable=False, comment='Стоимость перелета')
PrimaryKeyConstraint(ticket_id, flight_id)
class Boarding_Passes(Base):
__tablename__ = 'boarding_passes'
__table_args__ = {'schema': 'bookings'}
boarding_no = Column(BIGINT, nullable=False, unique=True, autoincrement=True, primary_key=True,
comment='Номер посадочного талона')
ticket_id = Column(BIGINT, ForeignKey('bookings.tickets.ticket_id'), nullable=False, unique=True,
comment='Номер билета')
seat_no = Column(String(4), nullable=False, comment='Номер места')
TICKET_ROWS = [
{
"ticket_id": 5432000987,
"passenger_id": "8149 604011",
"passenger_name": "VALERIY TIKHONOV"
},
{
"ticket_id": 5432000988,
"passenger_id": "8499 420203",
"passenger_name": "EVGENIYA ALEKSEEVA"
},
{
"ticket_id": 5432000989,
"passenger_id": "1011 752484",
"passenger_name": "ARTUR GERASIMOV"
},
{
"ticket_id": 5432000990,
"passenger_id": "4849 400049",
"passenger_name": "ALINA VOLKOVA"
},
{
"ticket_id": 5432000991,
"passenger_id": "6615 976589",
"passenger_name": "MAKSIM ZHUKOV"
},
{
"ticket_id": 5432000992,
"passenger_id": "2021 652719",
"passenger_name": "NIKOLAY EGOROV"
},
{
"ticket_id": 5432000993,
"passenger_id": "0817 363231",
"passenger_name": "TATYANA KUZNECOVA"
},
{
"ticket_id": 5432000994,
"passenger_id": "2883 989356",
"passenger_name": "IRINA ANTONOVA"
},
{
"ticket_id": 5432000995,
"passenger_id": "3097 995546",
"passenger_name": "VALENTINA KUZNECOVA"
},
{
"ticket_id": 5432000996,
"passenger_id": "6866 920231",
"passenger_name": "POLINA ZHURAVLEVA"
},
{
"ticket_id": 5432000997,
"passenger_id": "6030 369450",
"passenger_name": "ALEKSANDR TIKHONOV"
},
{
"ticket_id": 5432000998,
"passenger_id": "8675 588663",
"passenger_name": "ILYA POPOV"
},
{
"ticket_id": 5432000999,
"passenger_id": "0764 728785",
"passenger_name": "ALEKSANDR KUZNECOV"
},
{
"ticket_id": 5432001000,
"passenger_id": "8954 972101",
"passenger_name": "VSEVOLOD BORISOV"
},
{
"ticket_id": 5432001001,
"passenger_id": "6772 748756",
"passenger_name": "NATALYA ROMANOVA"
},
{
"ticket_id": 5432001002,
"passenger_id": "7364 216524",
"passenger_name": "ANTON BONDARENKO"
},
{
"ticket_id": 5432001003,
"passenger_id": "3635 182357",
"passenger_name": "VALENTINA NIKITINA"
},
{
"ticket_id": 5432001004,
"passenger_id": "8252 507584",
"passenger_name": "ALLA TARASOVA"
},
{
"ticket_id": 5432001005,
"passenger_id": "1026 982766",
"passenger_name": "OKSANA MOROZOVA"
},
{
"ticket_id": 5432001006,
"passenger_id": "7107 950192",
"passenger_name": "GENNADIY GERASIMOV"
},
{
"ticket_id": 5432001007,
"passenger_id": "4765 014996",
"passenger_name": "RAISA KONOVALOVA"
}
]
TICKET_FLIGHTS_ROWS = [
{
"ticket_id": 5432000987,
"flight_id": 28935,
"amount": 6200.00
},
{
"ticket_id": 5432000988,
"flight_id": 28935,
"amount": 6200.00
},
{
"ticket_id": 5432000990,
"flight_id": 28939,
"amount": 18500.00
},
{
"ticket_id": 5432000989,
"flight_id": 28939,
"amount": 6200.00
},
{
"ticket_id": 5432000991,
"flight_id": 28913,
"amount": 18500.00
},
{
"ticket_id": 5432000992,
"flight_id": 28913,
"amount": 6200.00
},
{
"ticket_id": 5432000993,
"flight_id": 28913,
"amount": 6200.00
},
{
"ticket_id": 5432000994,
"flight_id": 28912,
"amount": 6800.00
},
{
"ticket_id": 5432000995,
"flight_id": 28912,
"amount": 6200.00
},
{
"ticket_id": 5432000996,
"flight_id": 28929,
"amount": 6200.00
},
{
"ticket_id": 5432000998,
"flight_id": 28904,
"amount": 18500.00
},
{
"ticket_id": 5432000999,
"flight_id": 28904,
"amount": 6200.00
},
{
"ticket_id": 5432000997,
"flight_id": 28904,
"amount": 6200.00
},
{
"ticket_id": 5432001001,
"flight_id": 28895,
"amount": 6200.00
},
{
"ticket_id": 5432001000,
"flight_id": 28895,
"amount": 6200.00
},
{
"ticket_id": 5432001002,
"flight_id": 28895,
"amount": 6200.00
},
{
"ticket_id": 5432001003,
"flight_id": 28948,
"amount": 18500.00
},
{
"ticket_id": 5432001004,
"flight_id": 28948,
"amount": 6800.00
},
{
"ticket_id": 5432001005,
"flight_id": 28942,
"amount": 6200.00
},
{
"ticket_id": 5432001007,
"flight_id": 28915,
"amount": 6200.00
},
{
"ticket_id": 5432001006,
"flight_id": 28915,
"amount": 6200.00
}
]
BOARDING_PASSES_ROWS = [
{
"boarding_no": 5432000959,
"ticket_id": 5432000997,
"seat_no": "19F"
},
{
"boarding_no": 5432000962,
"ticket_id": 5432000989,
"seat_no": "18E"
},
{
"boarding_no": 5432000963,
"ticket_id": 5432001005,
"seat_no": "17C"
},
{
"boarding_no": 5432000965,
"ticket_id": 5432001006,
"seat_no": "16C"
},
{
"boarding_no": 5432000969,
"ticket_id": 5432000995,
"seat_no": "17A"
},
{
"boarding_no": 5432000970,
"ticket_id": 5432000993,
"seat_no": "19E"
},
{
"boarding_no": 5432000974,
"ticket_id": 5432000988,
"seat_no": "10E"
},
{
"boarding_no": 5432000977,
"ticket_id": 5432000987,
"seat_no": "7A"
},
{
"boarding_no": 5432000978,
"ticket_id": 5432001002,
"seat_no": "12C"
},
{
"boarding_no": 5432000979,
"ticket_id": 5432001000,
"seat_no": "11D"
},
{
"boarding_no": 5432000981,
"ticket_id": 5432001001,
"seat_no": "11A"
},
{
"boarding_no": 5432000982,
"ticket_id": 5432001007,
"seat_no": "8D"
},
{
"boarding_no": 5432000983,
"ticket_id": 5432000999,
"seat_no": "8F"
},
{
"boarding_no": 5432000984,
"ticket_id": 5432000996,
"seat_no": "14A"
},
{
"boarding_no": 5432000986,
"ticket_id": 5432000994,
"seat_no": "6F"
},
{
"boarding_no": 5432000987,
"ticket_id": 5432000992,
"seat_no": "5F"
},
{
"boarding_no": 5432000988,
"ticket_id": 5432000990,
"seat_no": "3F"
},
{
"boarding_no": 5432000989,
"ticket_id": 5432001004,
"seat_no": "6F"
},
{
"boarding_no": 5432000990,
"ticket_id": 5432000991,
"seat_no": "1D"
},
{
"boarding_no": 5432000996,
"ticket_id": 5432000998,
"seat_no": "2C"
},
{
"boarding_no": 5432001000,
"ticket_id": 5432001003,
"seat_no": "2C"
}
]
BOOKINGS_ROWS = {
Ticket: TICKET_ROWS,
Ticket_Flights: TICKET_FLIGHTS_ROWS,
Boarding_Passes: BOARDING_PASSES_ROWS
}
Следующим шагом создадим класс, который будет собственно поднимать из нужного нам Docker - образа контейнер, запускать в нём сервер баз данных, создавать сами базы данных и наполнять их тестовыми данными, которые мы ранее определили в модулях airline_db.py и bookings_db.py
db_test.py
from sqlalchemy import create_engine
from sqlalchemy.dialects.postgresql import insert
from testcontainers.postgres import PostgresContainer
from db_services.engine_factory import EngineFactory
from .airline_db import Base as Airline_Base, AIRLINE_ROWS
from .bookings_db import Base as Bookings_Base, BOOKINGS_ROWS
class MetaSingleton(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super(MetaSingleton, cls).__call__(*args, **kwargs)
return cls._instances[cls]
class TestBases(metaclass=MetaSingleton):
db = None
main_url = None
def __init__(self, db_image_name):
__engine = EngineFactory()
__engine.stand = 'localhost'
# Создание контейнера из образа DB_IMAGE
__postgres_container = PostgresContainer(image=db_image_name)
self.db = __postgres_container.start()
self.main_url = self.db.get_connection_url()
__BASES = {'airline': {'class': Airline_Base, 'rows': AIRLINE_ROWS},
'bookings': {'class': Bookings_Base, 'rows': BOOKINGS_ROWS}
}
# Создание баз, схем, наполнение данными
for __base_name, __base_data in __BASES.items():
self.create_base(base_name=__base_name)
__engine.user, __engine.passw = 'test', 'test'
__url = __engine.get_postgres_url(base_name=__base_name)
self.create_schema(schema_name=__base_name, url=__url)
__db_engine = __engine.get_engine(__base_name)
__base_data.get('class').metadata.create_all(__db_engine)
for __cls, __rows in __base_data.get('rows').items():
__db_engine.execute(insert(__cls).values(__rows))
def create_base(self, base_name):
__engine = create_engine(self.main_url)
__connection = __engine.connect()
__connection.execution_options(isolation_level='AUTOCOMMIT').execute(f'create database {base_name}')
__host, __port = self.main_url.replace('postgresql+psycopg2://test:test@', '').replace('/test', '').split(':')
__new_base_url = f'postgresql+psycopg2://test:test@{__host}:{__port}/{base_name}'
#Добавляем соединение с новой базой в EngineFactory
__engine = EngineFactory()
__engine.add_db(base_name=base_name, url=__new_base_url)
def create_schema(self, url, schema_name):
__engine = create_engine(url)
__connection = __engine.connect()
__connection.execution_options(isolation_level='AUTOCOMMIT').execute(f'create schema {schema_name}')
Здесь также реализуем singleton, т.к. мы хотим чтобы у нас поднимался только один testcontainers. Осталось дописать сами тесты:
tests.py
import pytest
from processing.airline import set_flight_status, get_flight_status
from processing.bookings import get_premium_psg_list
from .db_test import TestBases
@pytest.fixture(scope="session", autouse=True)
def test_db():
# Этот блок будет выполнен перед запуском тестов
test_base = TestBases(db_image_name='postgres:11.8')
yield
# Этот блок будет выполнен после окончания работы тестов
test_base.db.stop()
# Тест метода processing.bookings.get_premium_psg_list()
# В текущих тестовых данных, для limit=10000, корректный результат == 4
def test_get_premium_psg_list(test_db):
assert len(get_premium_psg_list(limit=10000)) == 4
# Тест метода processing.airline.get_flight_status()
# Для flight_id=33043 корректный результат 'On Time'
def test_get_flight_status_before(test_db):
assert get_flight_status(flight_id=33043) == [['On Time']]
# Тест метода processing.airline.set_flight_status()
# В таблице airline.flights только одна запись с flight_id=33043, поэтому корректный результат - 1
# !!!Тест меняет состояние тестовой среды!!!
def test_set_flight_status(test_db):
assert set_flight_status(flight_id=33043, status='Delayed') == 1
# Тест метода processing.airline.get_flight_status()
# После выполнения теста test_set_flight_status(test_db) состояние тестовой среды изменилось.
# Корректный результат теста для flight_id=33043 - 'Delayed'
def test_get_flight_status_after(test_db):
assert get_flight_status(flight_id=33043) == [['Delayed']]
# тест, для случая если нужно оставить активным докер-контейнер после завершения работы тестов
# def test_debug(test_db):
# while True:
# pass
Здесь мы определили 4 теста, на которых и будем выполнять тестирование. Но что более важно, здесь мы определили фикстуру test_db()
, внутри которой выполняется подготовка тестовой среды. Тестовую среду pytest будет создавать перед каждым тестом, который её использует, но т.к. мы указали scope="session"
, то подготовка тестовой среды будет производиться один раз для всей сессии выполнения тестов. И если какой-либо из тестов будет изменять состояние БД, то следующий тест будет использовать данные изменённой тестовой среды. Это необходимо учитывать. В частности, этот принцип используется в наших примерах.
Запускаем тесты и радуемся зелёными галочками в Test Result :)
После выполнения всех тестов, testcontainers завершит работу созданного контейнера и удалит созданные данные. Бывает полезно "придержать" тестовую БД на некоторое время, чтобы можно было залезть в БД из обычной IDE, чтобы выполнить пару-тройку SQL-запросов. Для этого нужно просто раскомментировать тест test_debug(test_db)
, который выполняясь в бесконечном цикле, позволит получить доступ к локальной БД под логином test
и паролем test
. Порт можно подсмотреть в Docker Desktop
либо из консоли:
# Список запущенных контейнеров:
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
46fb9a865f58 postgres:11.8 "docker-entrypoint.s…" 13 minutes ago Up 13 minutes 0.0.0.0:56517->5432/tcp clever_einstein
# Получаем порты нужного контейнера
docker port 46fb9a865f58
5432/tcp -> 0.0.0.0:56517
Итоги
Мы только что создали проект, в котором протестировали БД слой приложения, с помощью testcontainers и pytest. Конечно, если у вас есть возможность тестирования на реальной БД или на её реплике, то смысл использования testcontainers теряется, а подготовка тестовых баз и тестовых данных становится ненужной тратой рабочего времени. Альтернативой testcontainers также может стать создание отдельного сервера БД с нужными объектами. Но если ничего такого под рукой нет, а тестирование необходимо выполнять, testcontainers вполне может быть выходом в данной ситуации.
Скачать данный проект можно с моего репозитория GitHub.
Комментарии (5)
Andrey_Solomatin
19.07.2022 01:19Двойное подчёркивание в имени локальной переменной несколько перебор.
def __init__(self, db_image_name): __engine = EngineFactory() __engine.stand = 'localhost'
Синглтон тоже можно выкинуть, так как тесты это реализуют самостоятельно.
Комментарии дублируют код. Даже для обучающего кода это излишне.
# Тест метода processing.bookings.get_premium_psg_list() # В текущих тестовых данных, для limit=10000, корректный результат == 4 def test_get_premium_psg_list(test_db): assert len(get_premium_psg_list(limit=10000)) == 4
altyshevamaria
19.07.2022 02:14Использованная БД взята с edu.postgrespro.ru (для изучения postgresql)?
P. S. Спасибо за материал. Интересовал момент того, как дружить питон и postgres, у вас в коде отлично продемонстрирован.
Andrey_Solomatin
Я бы попробовал реализовать логику удаления как в фикстуре https://docs.pytest.org/en/6.2.x/reference.html#pytest.tmpdir.tmp_path
Ну ли просто не удалять по аргументу командной строки.
Z55 Автор
Тест с бесконечным циклом внутри, это конечно же костыль. Поэтому согласен полностью, если по каким-то причинам нужно регулярно оставлять контейнер в рабочем состоянии, или иметь возможность управления этой функцией, то нужно что-то иное, и как вариант - да, по аргументу командной строки.