Skip to content

Commit

Permalink
ci: Use testcontainer for running tests
Browse files Browse the repository at this point in the history
Instead of relying on services to be set up properly, use
`testcontainers` to create a container to run the test inside.

Tests can use either `TimescaleDBTestCase` or `PostgreSQLTestCase` to
select what kind of container to use.  If a Postgres container is
requested, it will be read from the environment variable
`TEST_CONTAINER_POSTGRES` and Timescale containers will be read from
`TEST_CONTAINER_TIMESCALE`.
  • Loading branch information
mkindahl committed Oct 2, 2023
1 parent 31f4826 commit b18cf1e
Show file tree
Hide file tree
Showing 6 changed files with 110 additions and 39 deletions.
5 changes: 4 additions & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pylint psycopg2 packaging
pip install pylint
if [ -r requirements.txt ]; then
pip install -r requirements.txt
fi
- name: Analysing the code with pylint
run: |
pylint $(git ls-files '*.py')
14 changes: 2 additions & 12 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,6 @@ jobs:
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10"]
services:
timescale:
image: timescale/timescaledb:latest-pg15
env:
POSTGRES_PASSWORD: xyzzy
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432

timeout-minutes: 25
steps:
Expand All @@ -54,4 +42,6 @@ jobs:
PGUSER: postgres
PGPASSWORD: xyzzy
PGPORT: 5432
TEST_CONTAINER_TIMESCALE: timescale/timescaledb:latest-pg15
TEST_CONTAINER_POSTGRES: postgres:15
run: python -m pytest
4 changes: 3 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
psycopg2>=2.9.2
psycopg2>=2.9.0
testcontainers>=3.7.0
packaging>=21.0
2 changes: 1 addition & 1 deletion src/doctor/rules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def is_rule_file(fname):
"""Check if file is a rules file."""
if not isfile(fname):
return False
if fname.endswith(['__init__.py', '_test.py']) or fname.startswith("test_"):
if fname.endswith(('__init__.py', '_test.py')) or fname.startswith("test_"):
return False
return True

Expand Down
31 changes: 7 additions & 24 deletions src/doctor/rules/compression_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,12 @@

"""Unit tests for compressed hypertable rules."""

import os
import unittest
import psycopg2

from psycopg2.extras import RealDictCursor
from timescaledb import Hypertable

from doctor.unittest import TimescaleDBTestCase
from doctor.rules.compression import LinearSegmentBy, PointlessSegmentBy

class TestCompressionRules(unittest.TestCase):
class TestCompressionRules(TimescaleDBTestCase):
"""Test compression rules.
This will create a hypertable where we segment-by a column that
Expand All @@ -34,24 +30,15 @@ class TestCompressionRules(unittest.TestCase):

def setUp(self):
"""Set up unit tests for compression rules."""
user = os.getenv("PGUSER")
host = os.getenv("PGHOST")
port = os.getenv("PGPORT") or "5432"
dbname = os.getenv("PGDATABASE")
password = os.getenv("PGPASSWORD")
print(f"connecting to {host}:{port} database {dbname}")
self.__conn = psycopg2.connect(dbname=dbname, user=user, host=host,
password=password, port=port,
cursor_factory=RealDictCursor)
table = Hypertable("conditions", "time", {
'time': "timestamptz not null",
'device_id': "integer",
'user_id': "integer",
'temperature': "float"
})
table.create(self.__conn)
table.create(self.connection)

with self.__conn.cursor() as cursor:
with self.connection.cursor() as cursor:
cursor.execute(
"INSERT INTO conditions "
"SELECT time, (random()*30)::int, 1, random()*80 - 40 "
Expand All @@ -64,17 +51,13 @@ def setUp(self):
")"
)
cursor.execute("ANALYZE conditions")
self.__conn.commit()
self.connection.commit()

def tearDown(self):
"""Tear down compression rules test."""
with self.__conn.cursor() as cursor:
with self.connection.cursor() as cursor:
cursor.execute("DROP TABLE conditions")
self.__conn.commit()

def run_rule(self, rule):
"""Run rule and return messages."""
return rule.execute(self.__conn, rule.message)
self.connection.commit()

def test_segmentby(self):
"""Test rule for detecting bad choice for segment-by column."""
Expand Down
93 changes: 93 additions & 0 deletions src/doctor/unittest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Copyright 2023 Timescale, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Unit tests support for Timescale Doctor rules."""

import os
import unittest

from abc import ABCMeta

import psycopg2

from psycopg2.extras import RealDictCursor
from testcontainers.postgres import PostgresContainer

class TestCase(unittest.TestCase, metaclass=ABCMeta):
"""Base class for Timescale Doctor unit tests.
Test cases are executed in a container that depends on the
requirements of the test case. These are typically used together
with the ``required`` decorator to attach an image to the test
case and run the tests there.
import doctor.unittest
@doctor.unittest.requires('timescaledb')
class TestCompressionRules(doctor.unittest.TestCase):
...
"""

container_name = None

@property
def connection(self):
"""Get database connection."""
return self.__connection

@property
def container(self):
"""Get container the test is running in."""
return self.__container

def run_rule(self, rule):
"""Run rule and return messages."""
return rule.execute(self.connection, rule.message)

@classmethod
def setUpClass(cls):
"""Start a container for the test case."""
assert cls.container_name is not None
print("Container name:", cls.container_name)
cls.__container = PostgresContainer(cls.container_name).start()
connstring = cls.__container.get_connection_url().replace("+psycopg2", "")
cls.__connection = psycopg2.connect(connstring, cursor_factory=RealDictCursor)

@classmethod
def tearDownClass(cls):
"""Close the connection and stop the container."""
cls.__connection.close()
cls.__container.stop()

class TimescaleDBTestCase(TestCase):
"""Base class for test cases that need TimescaleDB.
It will read the container name from the environment variable
"TEST_CONTAINER_TIMESCALE" if present, or default to
"timescaledb:latest-pg15".
"""

container_name = os.environ.get('TEST_CONTAINER_TIMESCALE', 'timescale/timescaledb:latest-pg15')

class PostgreSQLTestCase(TestCase):
"""Base class for test cases that use plain PostgreSQL.
It will read the container name from the environment variable
"TEST_CONTAINER_POSTGRES" if present, or default to
"postgres:latest".
"""

container_name = os.environ.get('TEST_CONTAINER_POSTGRES', 'postgres:latest')

0 comments on commit b18cf1e

Please sign in to comment.