Repository: popsim-consortium/stdpopsim (language: python)

Issue:
Suppress warnings from test suite
When we run the test suite now with pytest we get a lot of warnings. This will be confusing to contributors.

We can either suppress by default (``--disable-warnings``) by adding something to the ``pytest.ini`` file (yuck, but maybe no option) or we can try to absorb the errors where they are expected.

Should wait for #778 


Discussion / hints:

Fix patch:
diff --git a/stdpopsim/cache.py b/stdpopsim/cache.py
index e2a06bc1b..c36993a15 100644
--- a/stdpopsim/cache.py
+++ b/stdpopsim/cache.py
@@ -167,7 +167,7 @@ def download(self):
                 os.rename(local_path, self.cache_path)
             except (OSError, FileExistsError):
                 warnings.warn(
-                    "Error occured renaming map directory. Are multiple processes"
+                    "Error occured renaming map directory. Are multiple processes "
                     "downloading this map at the same time?"
                 )
                 return
diff --git a/stdpopsim/catalog/HomSap/annotations.py b/stdpopsim/catalog/HomSap/annotations.py
index 27f98117a..14c8cdd0a 100644
--- a/stdpopsim/catalog/HomSap/annotations.py
+++ b/stdpopsim/catalog/HomSap/annotations.py
@@ -23,4 +23,5 @@
         )
     ],
 )
-_species.add_annotations(_an)
+# XXX: zarr deleted from amazon
+# _species.add_annotations(_an)
diff --git a/stdpopsim/genomes.py b/stdpopsim/genomes.py
index d1e93caa8..3bf69fb91 100644
--- a/stdpopsim/genomes.py
+++ b/stdpopsim/genomes.py
@@ -3,8 +3,12 @@
 regions to be simulated.
 """
 import logging
+import warnings
 import attr
+
 import numpy as np
+import msprime
+
 import stdpopsim
 
 logger = logging.getLogger(__name__)
@@ -167,7 +171,7 @@ def __attrs_post_init__(self):
         stdpopsim.utils.check_intervals_validity(self.intervals)
 
 
-@attr.s
+@attr.s(kw_only=True)
 class Contig:
     """
     Class representing a contiguous region of genome that is to be
@@ -211,13 +215,128 @@ class Contig:
             )
     """
 
-    recombination_map = attr.ib(default=None, kw_only=True)
-    mutation_rate = attr.ib(default=None, type=float, kw_only=True)
-    genetic_map = attr.ib(default=None, kw_only=True)
-    inclusion_mask = attr.ib(default=None, kw_only=True)
-    exclusion_mask = attr.ib(default=None, kw_only=True)
-    genomic_element_types = attr.ib(factory=list, type=list, kw_only=True)
-    mutation_types = attr.ib(factory=list, type=list, kw_only=True)
+    recombination_map = attr.ib()
+    mutation_rate = attr.ib(type=float)
+    genetic_map = attr.ib(default=None)
+    inclusion_mask = attr.ib(default=None)
+    exclusion_mask = attr.ib(default=None)
+    genomic_element_types = attr.ib(factory=list)
+    mutation_types = attr.ib(factory=list)
+
+    def __attrs_post_init__(self):
+        self.fully_neutral()
+
+    @staticmethod
+    def basic_contig(*, length, mutation_rate=0, recombination_rate=0):
+        recomb_map = msprime.RateMap.uniform(length, recombination_rate)
+        return Contig(recombination_map=recomb_map, mutation_rate=mutation_rate)
+
+    @staticmethod
+    def species_contig(
+        *,
+        species,
+        chromosome=None,
+        genetic_map=None,
+        length_multiplier=1,
+        length=None,
+        mutation_rate=None,
+        inclusion_mask=None,
+        exclusion_mask=None,
+    ):
+        """
+        Build a Contig for a species.
+        """
+        # TODO: add non-autosomal support
+        non_autosomal_lower = ["x", "y", "m", "mt", "chrx", "chry", "chrm"]
+        if chromosome is not None and chromosome.lower() in non_autosomal_lower:
+            warnings.warn(
+                stdpopsim.NonAutosomalWarning(
+                    "Non-autosomal simulations are not yet supported. See "
+                    "https://github.com/popsim-consortium/stdpopsim/issues/383 and "
+                    "https://github.com/popsim-consortium/stdpopsim/issues/406"
+                )
+            )
+        if chromosome is None:
+            if genetic_map is not None:
+                raise ValueError("Cannot use genetic map with generic contic")
+            if length_multiplier != 1:
+                raise ValueError("Cannot use length multiplier for generic contig")
+            if inclusion_mask is not None or exclusion_mask is not None:
+                raise ValueError("Cannot use mask with generic contig")
+            if length is None:
+                raise ValueError("Must specify sequence length of generic contig")
+            L_tot = 0
+            r_tot = 0
+            u_tot = 0
+            for chrom_data in species.genome.chromosomes:
+                if chrom_data.id.lower() not in non_autosomal_lower:
+                    L_tot += chrom_data.length
+                    r_tot += chrom_data.length * chrom_data.recombination_rate
+                    u_tot += chrom_data.length * chrom_data.mutation_rate
+            if mutation_rate is None:
+                mutation_rate = u_tot / L_tot
+            r = r_tot / L_tot
+            contig = Contig.basic_contig(
+                length=length,
+                mutation_rate=mutation_rate,
+                recombination_rate=r,
+            )
+        else:
+            if length is not None:
+                raise ValueError("Cannot specify sequence length for named contig")
+            if inclusion_mask is not None and exclusion_mask is not None:
+                raise ValueError("Cannot specify both inclusion and exclusion masks")
+            chrom = species.genome.get_chromosome(chromosome)
+            if genetic_map is None:
+                logger.debug(f"Making flat chromosome {length_multiplier} * {chrom.id}")
+                gm = None
+                recomb_map = msprime.RateMap.uniform(
+                    round(chrom.length * length_multiplier), chrom.recombination_rate
+                )
+            else:
+                if length_multiplier != 1:
+                    raise ValueError("Cannot use length multiplier with empirical maps")
+                logger.debug(f"Getting map for {chrom.id} from {genetic_map}")
+                gm = species.get_genetic_map(genetic_map)
+                recomb_map = gm.get_chromosome_map(chrom.id)
+
+            inclusion_intervals = None
+            exclusion_intervals = None
+            if inclusion_mask is not None:
+                if length_multiplier != 1:
+                    raise ValueError("Cannot use length multiplier with mask")
+                if isinstance(inclusion_mask, str):
+                    inclusion_intervals = stdpopsim.utils.read_bed(
+                        inclusion_mask, chromosome
+                    )
+                else:
+                    inclusion_intervals = inclusion_mask
+            if exclusion_mask is not None:
+                if length_multiplier != 1:
+                    raise ValueError("Cannot use length multiplier with mask")
+                if isinstance(exclusion_mask, str):
+                    exclusion_intervals = stdpopsim.utils.read_bed(
+                        exclusion_mask, chromosome
+                    )
+                else:
+                    exclusion_intervals = exclusion_mask
+
+            if mutation_rate is None:
+                mutation_rate = chrom.mutation_rate
+
+            contig = stdpopsim.Contig(
+                recombination_map=recomb_map,
+                mutation_rate=mutation_rate,
+                genetic_map=gm,
+                inclusion_mask=inclusion_intervals,
+                exclusion_mask=exclusion_intervals,
+            )
+
+        return contig
+
+    @property
+    def length(self):
+        return self.recombination_map.sequence_length
 
     @property
     def all_intervals_array(self):
@@ -242,11 +361,17 @@ def add_mutation_types(self, mutation_types, proportions, genomic_element_type_i
         Adds mutation types with their respective proportions to the genomic
         element type provided.
         """
-        if genomic_element_type_id >= len(self.genomic_element_types):
+        if not (0 <= genomic_element_type_id < len(self.genomic_element_types)):
             raise ValueError(
                 f"Genomic element type of id {genomic_element_type_id} does not exist."
             )
 
+        if len(mutation_types) != len(proportions):
+            raise ValueError(
+                f"number of mutation_types ({len(mutation_types)}) doesn't match "
+                f"the number of proportions ({len(proportions)})"
+            )
+
         def _is_index(y, X):
             for i, e in enumerate(X):
                 if e is y:
@@ -270,11 +395,6 @@ def _is_index(y, X):
         )
 
     def add_genomic_element_type(self, intervals, mutation_types, proportions):
-        if self.recombination_map is None:
-            raise ValueError(
-                "You cannot add genomic element types to a "
-                "contig that has no recombination map."
-            )
         stdpopsim.utils.check_intervals_validity(intervals)
         ge_type = stdpopsim.GenomicElementType(intervals=intervals)
         self.genomic_element_types.append(ge_type)
@@ -289,10 +409,8 @@ def clear_genomic_mutation_types(self):
 
     def fully_neutral(self, slim_mutations=False, convert_to_substitution=True):
         self.clear_genomic_mutation_types()
-        if self.recombination_map is None:
-            raise ValueError("You must specify a recombination map.")
         self.add_genomic_element_type(
-            intervals=np.array([[0, int(self.recombination_map.sequence_length)]]),
+            intervals=np.array([[0, int(self.length)]]),
             mutation_types=[
                 stdpopsim.ext.MutationType(
                     convert_to_substitution=convert_to_substitution
@@ -308,7 +426,7 @@ def __str__(self):
             "mutation_rate={:.2G}, genetic_map={}, genomic_element_types={}, "
             "mutation_types={})"
         ).format(
-            self.recombination_map.sequence_length,
+            self.length,
             self.recombination_map.mean_rate,
             self.mutation_rate,
             gmap,
diff --git a/stdpopsim/slim_engine.py b/stdpopsim/slim_engine.py
index 901f7278d..7735582b5 100644
--- a/stdpopsim/slim_engine.py
+++ b/stdpopsim/slim_engine.py
@@ -1135,14 +1135,6 @@ def simulate(
                     "compare results across different values of the scaling factor."
                 )
             )
-        if contig.recombination_map is None:
-            raise ValueError(
-                "Your Contig object is not compatible with the SLiM "
-                "engine. You must specify a recombination map."
-            )
-        # if no GET were defined, fully neutral sim with overlay of muts using msprime
-        if len(contig.genomic_element_types) == 0:
-            contig.fully_neutral()
 
         # figuring out mutations in SLiM and neutral mutations to be added
         # by msprime later
@@ -1379,14 +1371,6 @@ def recap_and_rescale(
             should be consulted to determine if the behaviour is appropriate
             for your case.
         """
-        if contig.recombination_map is None:
-            raise ValueError(
-                "Your Contig object is not compatible with the SLiM "
-                "engine. You must specify a recombination map."
-            )
-        # if no GET were defined, fully neutral sim with overlay of muts using msprime
-        if len(contig.genomic_element_types) == 0:
-            contig.fully_neutral()
         msp_rate_map, slim_rate_map = get_msp_and_slim_mutation_rate_maps(contig)
 
         with open(os.devnull, "w") as script_file:
diff --git a/stdpopsim/species.py b/stdpopsim/species.py
index 193f08e48..6cf0ff4f0 100644
--- a/stdpopsim/species.py
+++ b/stdpopsim/species.py
@@ -3,10 +3,8 @@
 organising the species catalog.
 """
 import logging
-import warnings
 
 import attr
-import msprime
 
 import stdpopsim
 import stdpopsim.utils
@@ -187,92 +185,16 @@ def get_contig(
         :rtype: :class:`.Contig`
         :return: A :class:`.Contig` describing the section of the genome.
         """
-        # TODO: add non-autosomal support
-        non_autosomal_lower = ["x", "y", "m", "mt", "chrx", "chry", "chrm"]
-        if chromosome is not None and chromosome.lower() in non_autosomal_lower:
-            warnings.warn(
-                stdpopsim.NonAutosomalWarning(
-                    "Non-autosomal simulations are not yet supported. See "
-                    "https://github.com/popsim-consortium/stdpopsim/issues/383 and "
-                    "https://github.com/popsim-consortium/stdpopsim/issues/406"
-                )
-            )
-        if chromosome is None:
-            if genetic_map is not None:
-                raise ValueError("Cannot use genetic map with generic contic")
-            if length_multiplier != 1:
-                raise ValueError("Cannot use length multiplier for generic contig")
-            if inclusion_mask is not None or exclusion_mask is not None:
-                raise ValueError("Cannot use mask with generic contig")
-            if length is None:
-                raise ValueError("Must specify sequence length of generic contig")
-            L_tot = 0
-            r_tot = 0
-            u_tot = 0
-            for chrom_data in self.genome.chromosomes:
-                if chrom_data.id.lower() not in non_autosomal_lower:
-                    L_tot += chrom_data.length
-                    r_tot += chrom_data.length * chrom_data.recombination_rate
-                    u_tot += chrom_data.length * chrom_data.mutation_rate
-            if mutation_rate is None:
-                mutation_rate = u_tot / L_tot
-            r = r_tot / L_tot
-            recomb_map = msprime.RateMap.uniform(length, r)
-            ret = stdpopsim.Contig(
-                recombination_map=recomb_map, mutation_rate=mutation_rate
-            )
-        else:
-            if length is not None:
-                raise ValueError("Cannot specify sequence length for named contig")
-            if inclusion_mask is not None and exclusion_mask is not None:
-                raise ValueError("Cannot specify both inclusion and exclusion masks")
-            chrom = self.genome.get_chromosome(chromosome)
-            if genetic_map is None:
-                logger.debug(f"Making flat chromosome {length_multiplier} * {chrom.id}")
-                gm = None
-                recomb_map = msprime.RateMap.uniform(
-                    round(chrom.length * length_multiplier), chrom.recombination_rate
-                )
-            else:
-                if length_multiplier != 1:
-                    raise ValueError("Cannot use length multiplier with empirical maps")
-                logger.debug(f"Getting map for {chrom.id} from {genetic_map}")
-                gm = self.get_genetic_map(genetic_map)
-                recomb_map = gm.get_chromosome_map(chrom.id)
-
-            inclusion_intervals = None
-            exclusion_intervals = None
-            if inclusion_mask is not None:
-                if length_multiplier != 1:
-                    raise ValueError("Cannot use length multiplier with mask")
-                if isinstance(inclusion_mask, str):
-                    inclusion_intervals = stdpopsim.utils.read_bed(
-                        inclusion_mask, chromosome
-                    )
-                else:
-                    inclusion_intervals = inclusion_mask
-            if exclusion_mask is not None:
-                if length_multiplier != 1:
-                    raise ValueError("Cannot use length multiplier with mask")
-                if isinstance(exclusion_mask, str):
-                    exclusion_intervals = stdpopsim.utils.read_bed(
-                        exclusion_mask, chromosome
-                    )
-                else:
-                    exclusion_intervals = exclusion_mask
-
-            if mutation_rate is None:
-                mutation_rate = chrom.mutation_rate
-
-            ret = stdpopsim.Contig(
-                recombination_map=recomb_map,
-                mutation_rate=mutation_rate,
-                genetic_map=gm,
-                inclusion_mask=inclusion_intervals,
-                exclusion_mask=exclusion_intervals,
-            )
-
-        return ret
+        return stdpopsim.Contig.species_contig(
+            species=self,
+            chromosome=chromosome,
+            genetic_map=genetic_map,
+            length_multiplier=length_multiplier,
+            length=length,
+            mutation_rate=mutation_rate,
+            inclusion_mask=inclusion_mask,
+            exclusion_mask=exclusion_mask,
+        )
 
     def get_demographic_model(self, id):
         """


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index 93058b7a9..6f5f0fb85 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,13 +1,12 @@
 """
 Package definition for tests. Defined to allow cross-importing.
 """
-import unittest
 import tempfile
 
 import stdpopsim
 
 
-class CacheReadingTest(unittest.TestCase):
+class CacheReadingTest:
     """
     This should be used as the superclass of all tests that use downloaded data.
     Rather than using the standard userdir for downloaded data, we use this
@@ -19,15 +18,17 @@ class CacheReadingTest(unittest.TestCase):
     saved_cache_dir = None
     saved_urls = {}
 
-    def setUp(self):
-        self.saved_cache_dir = stdpopsim.get_cache_dir()
-        stdpopsim.set_cache_dir(self.cache_dir)
+    @classmethod
+    def setup_class(cls):
+        cls.saved_cache_dir = stdpopsim.get_cache_dir()
+        stdpopsim.set_cache_dir(cls.cache_dir)
 
-    def tearDown(self):
-        stdpopsim.set_cache_dir(self.saved_cache_dir)
+    @classmethod
+    def teardown_class(cls):
+        stdpopsim.set_cache_dir(cls.saved_cache_dir)
 
 
-class CacheWritingTest(unittest.TestCase):
+class CacheWritingTest:
     """
     This should be used as the superclass of all tests that alter the
     downloaded data cache in any non-standard way.
@@ -36,11 +37,13 @@ class CacheWritingTest(unittest.TestCase):
     saved_cache_dir = None
     tmp_cache_dir = None
 
-    def setUp(self):
-        self.saved_cache_dir = stdpopsim.get_cache_dir()
-        self.tmp_cache_dir = tempfile.TemporaryDirectory()
-        stdpopsim.set_cache_dir(self.tmp_cache_dir.name)
+    @classmethod
+    def setup_class(cls):
+        cls.saved_cache_dir = stdpopsim.get_cache_dir()
+        cls.tmp_cache_dir = tempfile.TemporaryDirectory()
+        stdpopsim.set_cache_dir(cls.tmp_cache_dir.name)
 
-    def tearDown(self):
-        stdpopsim.set_cache_dir(self.saved_cache_dir)
-        del self.tmp_cache_dir
+    @classmethod
+    def teardown_class(cls):
+        stdpopsim.set_cache_dir(cls.saved_cache_dir)
+        del cls.tmp_cache_dir
diff --git a/tests/test_annotations.py b/tests/test_annotations.py
index 2d35cffa2..3b75cbb26 100644
--- a/tests/test_annotations.py
+++ b/tests/test_annotations.py
@@ -1,7 +1,6 @@
 """
 Tests for the annotations management.
 """
-import unittest
 from unittest import mock
 import gzip
 import tempfile
@@ -10,6 +9,7 @@
 import pathlib
 
 import pandas
+import pytest
 
 import stdpopsim
 from stdpopsim import utils
@@ -26,7 +26,7 @@
 saved_urls = {}
 
 
-def setUpModule():
+def setup_module():
     destination = pathlib.Path("_test_cache/zipfiles/")
     for an in stdpopsim.all_annotations():
         key = an._cache.cache_path
@@ -49,7 +49,7 @@ def setUpModule():
         an._cache.url = an.zarr_url
 
 
-def tearDownModule():
+def teardown_module():
     for an in stdpopsim.all_annotations():
         an.zarr_url = saved_urls[an._cache.cache_path]
         an._cache.url = an.zarr_url
@@ -112,11 +112,11 @@ class TestAnnotation(tests.CacheWritingTest):
     def test_cache_dirs(self):
         an = AnnotationTestClass()
         cache_dir = stdpopsim.get_cache_dir() / "annotations" / an.species.id
-        self.assertEqual(an.cache_path.parent, cache_dir)
+        assert an.cache_path.parent == cache_dir
 
     def test_str(self):
         an = AnnotationTestClass()
-        self.assertGreater(len(str(an)), 0)
+        assert len(str(an)) > 0
 
 
 class TestAnnotationDownload(tests.CacheWritingTest):
@@ -128,27 +128,29 @@ def test_correct_url(self):
         an = AnnotationTestClass()
         with mock.patch("stdpopsim.utils.download", autospec=True) as mocked_get:
             # The destination file will be missing.
-            with self.assertRaises(FileNotFoundError):
+            with pytest.raises(FileNotFoundError):
                 an.download()
-        mocked_get.assert_called_once_with(an.zarr_url, filename=unittest.mock.ANY)
+        mocked_get.assert_called_once_with(an.zarr_url, filename=mock.ANY)
 
     def test_incorrect_url(self):
         an = AnnotationTestClass()
         an.zarr_url = "http://asdfwersdf.com/foozip"
-        with self.assertRaises(OSError):
+        with pytest.raises(OSError):
             an.download()
 
+    @pytest.mark.xfail  # HomSap annotation not currently available
     def test_download_over_cache(self):
         # TODO: The HomSap annotations are huge. Once we include a smaller
         # annotation set, we should instead use that, so tests are faster.
         species = stdpopsim.get_species("HomSap")
         an = species.get_annotations("Ensembl_GRCh38_gff3")
         an.download()
-        self.assertTrue(an.is_cached())
+        assert an.is_cached()
         an.download()
-        self.assertTrue(an.is_cached())
+        assert an.is_cached()
 
 
+@pytest.mark.xfail  # HomSap annotation not currently available
 class TestGetChromosomeAnnotations(tests.CacheReadingTest):
     """
     Tests if we get chromosome level annotations
@@ -157,31 +159,33 @@ class TestGetChromosomeAnnotations(tests.CacheReadingTest):
 
     # TODO: The HomSap annotations are huge. Once we include a smaller
     # annotation set, we should instead use that, so tests are faster.
-    species = stdpopsim.get_species("HomSap")
-    an = species.get_annotations("Ensembl_GRCh38_gff3")
+    @classmethod
+    def setup_class(cls):
+        species = stdpopsim.get_species("HomSap")
+        cls.an = species.get_annotations("Ensembl_GRCh38_gff3")
 
     def test_known_chromosome(self):
         cm = self.an.get_chromosome_annotations("21")
-        self.assertIsInstance(cm, pandas.DataFrame)
+        assert isinstance(cm, pandas.DataFrame)
 
     def test_known_chromosome_prefix(self):
         cm = self.an.get_chromosome_annotations("chr21")
-        self.assertIsInstance(cm, pandas.DataFrame)
+        assert isinstance(cm, pandas.DataFrame)
 
     def test_unknown_chromosome(self):
         for bad_chrom in ["", "ABD", None]:
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 self.an.get_chromosome_annotations(bad_chrom)
 
     def test_get_genes(self):
         g = self.an.get_genes_from_chromosome("21")
-        self.assertIsInstance(g, pandas.DataFrame)
+        assert isinstance(g, pandas.DataFrame)
 
     def test_get_genes_full(self):
         g = self.an.get_genes_from_chromosome("21", full_table=True)
-        self.assertIsInstance(g, pandas.DataFrame)
+        assert isinstance(g, pandas.DataFrame)
 
     def test_bad_annot_type(self):
         bad_annot = "foo"
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             self.an.get_annotation_type_from_chromomosome(bad_annot, "21")
diff --git a/tests/test_cache.py b/tests/test_cache.py
index 3524c438a..116fa4fae 100644
--- a/tests/test_cache.py
+++ b/tests/test_cache.py
@@ -7,6 +7,7 @@
 import tarfile
 
 import appdirs
+import pytest
 
 import stdpopsim
 from stdpopsim import utils
@@ -23,21 +24,21 @@ class TestSetCacheDir(tests.CacheWritingTest):
     def test_paths(self):
         for test in self.paths:
             stdpopsim.set_cache_dir(test)
-            self.assertEqual(stdpopsim.get_cache_dir(), pathlib.Path(test))
+            assert stdpopsim.get_cache_dir() == pathlib.Path(test)
             stdpopsim.set_cache_dir(pathlib.Path(test))
-            self.assertEqual(stdpopsim.get_cache_dir(), pathlib.Path(test))
+            assert stdpopsim.get_cache_dir() == pathlib.Path(test)
 
     def test_none(self):
         stdpopsim.set_cache_dir(None)
         cache_dir = pathlib.Path(appdirs.user_cache_dir("stdpopsim", "popgensims"))
-        self.assertEqual(stdpopsim.get_cache_dir(), cache_dir)
+        assert stdpopsim.get_cache_dir() == cache_dir
 
     def test_environment_var(self):
         try:
             for test in self.paths:
                 os.environ["STDPOPSIM_CACHE"] = test
                 stdpopsim.set_cache_dir()
-                self.assertEqual(stdpopsim.get_cache_dir(), pathlib.Path(test))
+                assert stdpopsim.get_cache_dir() == pathlib.Path(test)
         finally:
             os.environ.pop("STDPOPSIM_CACHE")
 
@@ -62,27 +63,27 @@ def test_caching(self):
                     sha256=sha256,
                     extract=extract,
                 )
-                self.assertFalse(cache.is_cached())
-                self.assertFalse(cache.is_valid())
+                assert not (cache.is_cached())
+                assert not (cache.is_valid())
                 cache.download()
-                self.assertTrue(cache.is_cached())
-                self.assertTrue(cache.is_valid())
+                assert cache.is_cached()
+                assert cache.is_valid()
 
                 # try to download with incorrect checksum
                 cache.sha256 = "1234"
-                self.assertTrue(cache.is_cached())
-                self.assertFalse(cache.is_valid())
-                with self.assertRaises(ValueError):
+                assert cache.is_cached()
+                assert not (cache.is_valid())
+                with pytest.raises(ValueError):
                     # checksum mismatch
                     cache.download()
-                self.assertFalse(cache.is_cached())
-                self.assertFalse(cache.is_valid())
+                assert not (cache.is_cached())
+                assert not (cache.is_valid())
 
                 # fix the checksum and download again
                 cache.sha256 = sha256
                 cache.download()
-                self.assertTrue(cache.is_cached())
-                self.assertTrue(cache.is_valid())
+                assert cache.is_cached()
+                assert cache.is_valid()
 
     def test_multiple_threads_downloading(self):
         with tempfile.TemporaryDirectory() as tmpdir:
@@ -105,5 +106,5 @@ def test_multiple_threads_downloading(self):
             # Trick the download code into thinking there's several happening
             # concurrently
             cache.is_cached = lambda: False
-            with self.assertWarns(UserWarning):
+            with pytest.warns(UserWarning, match="multiple processes downloading"):
                 cache.download()
diff --git a/tests/test_citations.py b/tests/test_citations.py
index 074d84dac..6d49d2d4a 100644
--- a/tests/test_citations.py
+++ b/tests/test_citations.py
@@ -2,11 +2,11 @@
 Test cases for methods related to citations.
 """
 
-import unittest
 from unittest import mock
 import urllib.error
 
 import stdpopsim
+import pytest
 
 
 class mocked_response:
@@ -33,7 +33,7 @@ def close(self):
         pass
 
 
-class TestFetchBibtex(unittest.TestCase):
+class TestFetchBibtex:
     """
     Test the fetching of bibtex files."""
 
@@ -47,13 +47,13 @@ def test_get_bibtex_success(self):
         ):
             with mock.patch("urllib.request.Request", autospec=True):
                 bib = citation.fetch_bibtex()
-                self.assertEqual(bib, "test")
+                assert bib == "test"
 
     def test_get_bibtex_bad_connection(self):
         # Tests an invalid URL
         # Asserts that it raises a value error.
         citation = stdpopsim.Citation(doi="DOI", author="Authors", year="2000")
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             citation.fetch_bibtex()
 
     def test_get_bibtex_404(self):
@@ -61,11 +61,11 @@ def test_get_bibtex_404(self):
         citation = stdpopsim.Citation(
             doi="http://doi.org/url-does-not-exist", author="Authors", year="2000"
         )
-        with self.assertRaises(urllib.error.HTTPError):
+        with pytest.raises(urllib.error.HTTPError):
             citation.fetch_bibtex()
 
 
-class TestEverythingHappensForAReason(unittest.TestCase):
+class TestEverythingHappensForAReason:
     """
     Test that citations have a reason.
     """
@@ -73,30 +73,24 @@ class TestEverythingHappensForAReason(unittest.TestCase):
     def test_reasons_for_engine_citations(self):
         for engine in stdpopsim.all_engines():
             for citation in engine.citations:
-                self.assertGreater(
-                    len(citation.reasons),
-                    0,
-                    msg=f"No reason given for '{citation.author}' citation "
-                    f"in engine {engine.id}",
+                assert len(citation.reasons) > 0, (
+                    f"No reason given for '{citation.author}' citation "
+                    f"in engine {engine.id}"
                 )
 
     def test_reason_for_genetic_map_citations(self):
         for genetic_map in stdpopsim.all_genetic_maps():
             for citation in genetic_map.citations:
-                self.assertGreater(
-                    len(citation.reasons),
-                    0,
-                    msg=f"No reason given for '{citation.author}' citation "
+                assert len(citation.reasons) > 0, (
+                    f"No reason given for '{citation.author}' citation "
                     f"in genetic map "
-                    f"{genetic_map.species.id}/{genetic_map.id}",
+                    f"{genetic_map.species.id}/{genetic_map.id}"
                 )
 
     def test_reason_for_model_citations(self):
         for model in stdpopsim.all_demographic_models():
             for citation in model.citations:
-                self.assertGreater(
-                    len(citation.reasons),
-                    0,
-                    msg=f"No reason given for '{citation.author}' citation "
-                    f"in model {model.id}",
+                assert len(citation.reasons) > 0, (
+                    f"No reason given for '{citation.author}' citation "
+                    f"in model {model.id}"
                 )
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 05e264bb8..8a7274469 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,7 +1,6 @@
 """
 Test cases for the command line interfaces to stdpopsim
 """
-import unittest
 import tempfile
 import pathlib
 import subprocess
@@ -11,7 +10,6 @@
 import argparse  # NOQA
 import os
 import logging
-import warnings
 from unittest import mock
 
 import tskit
@@ -51,7 +49,7 @@ def capture_output(func, *args, **kwargs):
     return stdout_output, stderr_output
 
 
-class TestProvenance(unittest.TestCase):
+class TestProvenance:
     """
     Test basic provenance properties.
     """
@@ -64,29 +62,25 @@ def test_environment(self):
         # Basic environment should be the same as tskit.
         d_stdpopsim = cli.get_provenance_dict()
         d_tskit = tskit.provenance.get_provenance_dict()
-        self.assertEqual(d_stdpopsim["environment"]["os"], d_tskit["environment"]["os"])
-        self.assertEqual(
-            d_stdpopsim["environment"]["python"], d_tskit["environment"]["python"]
-        )
+        assert d_stdpopsim["environment"]["os"] == d_tskit["environment"]["os"]
+        assert d_stdpopsim["environment"]["python"] == d_tskit["environment"]["python"]
 
     def test_libraries(self):
         libs = cli.get_provenance_dict()["environment"]["libraries"]
-        self.assertEqual(libs["tskit"]["version"], tskit.__version__)
-        self.assertEqual(libs["msprime"]["version"], msprime.__version__)
+        assert libs["tskit"]["version"] == tskit.__version__
+        assert libs["msprime"]["version"] == msprime.__version__
 
     def test_software(self):
         software = cli.get_provenance_dict()["software"]
-        self.assertEqual(
-            software, {"name": "stdpopsim", "version": stdpopsim.__version__}
-        )
+        assert software == {"name": "stdpopsim", "version": stdpopsim.__version__}
 
     def test_parameters(self):
         d = cli.get_provenance_dict()["parameters"]
-        self.assertEqual(d["command"], sys.argv[0])
-        self.assertEqual(d["args"], sys.argv[1:])
+        assert d["command"] == sys.argv[0]
+        assert d["args"] == sys.argv[1:]
 
 
-class TestDownloadGeneticMapsArgumentParser(unittest.TestCase):
+class TestDownloadGeneticMapsArgumentParser:
     """
     Tests for the download-genetic-maps parser
     """
@@ -95,50 +89,50 @@ def test_defaults(self):
         parser = cli.stdpopsim_cli_parser()
         cmd = "download-genetic-maps"
         args = parser.parse_args([cmd])
-        self.assertEqual(args.species, None)
-        self.assertEqual(len(args.genetic_maps), 0)
+        assert args.species is None
+        assert len(args.genetic_maps) == 0
 
     def test_species_no_maps(self):
         parser = cli.stdpopsim_cli_parser()
         cmd = "download-genetic-maps some_species"
         args = parser.parse_args(cmd.split())
-        self.assertEqual(args.species, "some_species")
-        self.assertEqual(len(args.genetic_maps), 0)
+        assert args.species == "some_species"
+        assert len(args.genetic_maps) == 0
 
     def test_species_one_map(self):
         parser = cli.stdpopsim_cli_parser()
         cmd = "download-genetic-maps some_species map1"
         args = parser.parse_args(cmd.split())
-        self.assertEqual(args.species, "some_species")
-        self.assertEqual(args.genetic_maps, ["map1"])
+        assert args.species == "some_species"
+        assert args.genetic_maps == ["map1"]
 
     def test_species_two_maps(self):
         parser = cli.stdpopsim_cli_parser()
         cmd = "download-genetic-maps some_species map1 map2"
         args = parser.parse_args(cmd.split())
-        self.assertEqual(args.species, "some_species")
-        self.assertEqual(args.genetic_maps, ["map1", "map2"])
+        assert args.species == "some_species"
+        assert args.genetic_maps == ["map1", "map2"]
 
     def test_verbosity(self):
         parser = cli.stdpopsim_cli_parser()
         cmd = "download-genetic-maps"
         args = parser.parse_args(cmd.split())
-        self.assertEqual(args.verbose, 1)
+        assert args.verbose == 1
 
         cmd = "-v download-genetic-maps"
         args = parser.parse_args(cmd.split())
-        self.assertEqual(args.verbose, 2)
+        assert args.verbose == 2
 
         cmd = "-vv download-genetic-maps"
         args = parser.parse_args(cmd.split())
-        self.assertEqual(args.verbose, 3)
+        assert args.verbose == 3
 
         cmd = "-q download-genetic-maps"
         args = parser.parse_args(cmd.split())
-        self.assertEqual(args.verbose, 0)
+        assert args.verbose == 0
 
 
-class TestHomoSapiensArgumentParser(unittest.TestCase):
+class TestHomoSapiensArgumentParser:
     """
     Tests for the argument parsers.
     """
@@ -147,9 +141,9 @@ def test_defaults(self):
         parser = cli.stdpopsim_cli_parser()
         cmd = "HomSap"
         args = parser.parse_args([cmd, "2"])
-        self.assertEqual(args.output, None)
-        self.assertEqual(args.seed, None)
-        self.assertEqual(args.samples, [2])
+        assert args.output is None
+        assert args.seed is None
+        assert args.samples == [2]
 
     def test_output(self):
         parser = cli.stdpopsim_cli_parser()
@@ -157,38 +151,38 @@ def test_output(self):
         output = "/stuff/tmp.trees"
 
         args = parser.parse_args([cmd, "2", "-o", output])
-        self.assertEqual(args.output, output)
-        self.assertEqual(args.samples, [2])
+        assert args.output == output
+        assert args.samples == [2]
 
         args = parser.parse_args([cmd, "-o", output, "2"])
-        self.assertEqual(args.output, output)
-        self.assertEqual(args.samples, [2])
+        assert args.output == output
+        assert args.samples == [2]
 
         args = parser.parse_args([cmd, "--output", output, "2"])
-        self.assertEqual(args.output, output)
-        self.assertEqual(args.samples, [2])
+        assert args.output == output
+        assert args.samples == [2]
 
     def test_seed(self):
         parser = cli.stdpopsim_cli_parser()
         cmd = "HomSap"
         args = parser.parse_args([cmd, "2", "-s", "1234"])
-        self.assertEqual(args.samples, [2])
-        self.assertEqual(args.seed, 1234)
+        assert args.samples == [2]
+        assert args.seed == 1234
 
         args = parser.parse_args([cmd, "2", "--seed", "14"])
-        self.assertEqual(args.samples, [2])
-        self.assertEqual(args.seed, 14)
+        assert args.samples == [2]
+        assert args.seed == 14
 
     def test_cache_dir(self):
         parser = cli.stdpopsim_cli_parser()
         cmd = "HomSap"
         args = parser.parse_args(["-c", "cache_dir", cmd, "2"])
-        self.assertEqual(args.samples, [2])
-        self.assertEqual(args.cache_dir, "cache_dir")
+        assert args.samples == [2]
+        assert args.cache_dir == "cache_dir"
 
         args = parser.parse_args(["--cache-dir", "/some/cache_dir", cmd, "2"])
-        self.assertEqual(args.samples, [2])
-        self.assertEqual(args.cache_dir, "/some/cache_dir")
+        assert args.samples == [2]
+        assert args.cache_dir == "/some/cache_dir"
 
     def test_bibtex(self):
         parser = cli.stdpopsim_cli_parser()
@@ -198,12 +192,12 @@ def test_bibtex(self):
 
         with mock.patch.object(argparse.FileType, "__call__", autospec=True) as call:
             args = parser.parse_args([cmd, "-b", bib, "-o", output, "2"])
-            self.assertEqual(args.output, output)
-            self.assertEqual(args.samples, [2])
+            assert args.output == output
+            assert args.samples == [2]
             call.assert_called_with(mock.ANY, bib)
 
 
-class TestEndToEnd(unittest.TestCase):
+class TestEndToEnd:
     """
     Checks that simulations we run from the CLI have plausible looking output.
     """
@@ -214,10 +208,10 @@ def verify(self, cmd, num_samples, seed=1):
             full_cmd = f" -q {cmd} -o {filename} --seed={seed}"
             with mock.patch("stdpopsim.cli.setup_logging", autospec=True):
                 stdout, stderr = capture_output(cli.stdpopsim_main, full_cmd.split())
-            self.assertEqual(len(stderr), 0)
-            self.assertEqual(len(stdout), 0)
+            assert len(stderr) == 0
+            assert len(stdout) == 0
             ts = tskit.load(str(filename))
-        self.assertEqual(ts.num_samples, num_samples)
+        assert ts.num_samples == num_samples
 
     def test_homsap_seed(self):
         cmd = "HomSap -c chr20 -l0.1 -s 1234 20"
@@ -276,17 +270,17 @@ def verify(self, cmd, num_samples, seed=1):
             full_cmd = f"{sys.executable} -m stdpopsim -q {cmd} -o {filename} -s {seed}"
             subprocess.run(full_cmd, shell=True, check=True)
             ts = tskit.load(str(filename))
-        self.assertEqual(ts.num_samples, num_samples)
+        assert ts.num_samples == num_samples
         provenance = json.loads(ts.provenance(ts.num_provenances - 1).record)
         tskit.validate_provenance(provenance)
         stored_cmd = provenance["parameters"]["args"]
-        self.assertEqual(stored_cmd[0], "-q")
-        self.assertEqual(stored_cmd[-1], str(seed))
-        self.assertEqual(stored_cmd[-2], "-s")
-        self.assertEqual(stored_cmd[1:-4], cmd.split())
+        assert stored_cmd[0] == "-q"
+        assert stored_cmd[-1] == str(seed)
+        assert stored_cmd[-2] == "-s"
+        assert stored_cmd[1:-4] == cmd.split()
 
 
-class TestWriteOutput(unittest.TestCase):
+class TestWriteOutput:
     """
     Tests the paths through the write_output function.
     """
@@ -311,7 +305,7 @@ def test_to_file(self):
             mocked_dump.assert_called_once_with(mock.ANY, output_file)
 
 
-class TestRedirection(unittest.TestCase):
+class TestRedirection:
     """
     Tests that the tree sequence file we get from redirecting is identical to the
     the one we get from using the --output option.
@@ -323,11 +317,11 @@ def verify_files(self, filename1, filename2):
         tables2 = tskit.load(filename2).dump_tables()
         tables1.provenances.clear()
         tables2.provenances.clear()
-        self.assertEqual(tables1, tables2)
+        assert tables1 == tables2
 
         # Load the files into kastore to do some extra checks.
         with kastore.load(filename1) as store1, kastore.load(filename2) as store2:
-            self.assertEqual(set(store1.keys()), set(store2.keys()))
+            assert set(store1.keys()) == set(store2.keys())
 
     def verify(self, cmd):
 
@@ -341,7 +335,7 @@ def verify(self, cmd):
                 stderr=subprocess.PIPE,
                 stdout=subprocess.PIPE,
             )
-            self.assertEqual(len(result.stdout), 0)
+            assert len(result.stdout) == 0
 
             filename2 = pathlib.Path(tmpdir) / "output2.trees"
             full_cmd = f"{sys.executable} -m stdpopsim {cmd}"
@@ -365,7 +359,7 @@ def test_no_quiet(self):
         self.verify(cmd)
 
 
-class TestArgumentParsing(unittest.TestCase):
+class TestArgumentParsing:
     """
     Tests that basic argument parsing works as expected.
     """
@@ -375,24 +369,24 @@ class TestArgumentParsing(unittest.TestCase):
     def test_quiet_verbose(self):
         parser = cli.stdpopsim_cli_parser()
         args = parser.parse_args(["-q"] + self.basic_cmd)
-        self.assertEqual(args.verbose, 0)
+        assert args.verbose == 0
         args = parser.parse_args(["--quiet"] + self.basic_cmd)
-        self.assertEqual(args.verbose, 0)
+        assert args.verbose == 0
         args = parser.parse_args(self.basic_cmd)
-        self.assertEqual(args.verbose, 1)
+        assert args.verbose == 1
         args = parser.parse_args(["-v"] + self.basic_cmd)
-        self.assertEqual(args.verbose, 2)
+        assert args.verbose == 2
         args = parser.parse_args(["--verbose"] + self.basic_cmd)
-        self.assertEqual(args.verbose, 2)
+        assert args.verbose == 2
         args = parser.parse_args(["-v", "-v"] + self.basic_cmd)
-        self.assertEqual(args.verbose, 3)
+        assert args.verbose == 3
         args = parser.parse_args(["-v", "--verbose"] + self.basic_cmd)
-        self.assertEqual(args.verbose, 3)
+        assert args.verbose == 3
         args = parser.parse_args(["--verbose", "--verbose"] + self.basic_cmd)
-        self.assertEqual(args.verbose, 3)
+        assert args.verbose == 3
 
 
-class TestLogging(unittest.TestCase):
+class TestLogging:
     """
     Tests that logging has the desired effect.
     """
@@ -405,7 +399,7 @@ def test_quiet(self):
         with mock.patch("logging.basicConfig", autospec=True) as mocked_config:
             cli.setup_logging(args)
         _, kwargs = mocked_config.call_args
-        self.assertEqual(kwargs["level"], "ERROR")
+        assert kwargs["level"] == "ERROR"
 
     def test_default(self):
         parser = cli.stdpopsim_cli_parser()
@@ -413,7 +407,7 @@ def test_default(self):
         with mock.patch("logging.basicConfig", autospec=True) as mocked_config:
             cli.setup_logging(args)
         _, kwargs = mocked_config.call_args
-        self.assertEqual(kwargs["level"], "WARN")
+        assert kwargs["level"] == "WARN"
 
     def test_verbose(self):
         parser = cli.stdpopsim_cli_parser()
@@ -421,7 +415,7 @@ def test_verbose(self):
         with mock.patch("logging.basicConfig", autospec=True) as mocked_config:
             cli.setup_logging(args)
         _, kwargs = mocked_config.call_args
-        self.assertEqual(kwargs["level"], "INFO")
+        assert kwargs["level"] == "INFO"
 
     def test_very_verbose(self):
         parser = cli.stdpopsim_cli_parser()
@@ -429,8 +423,10 @@ def test_very_verbose(self):
         with mock.patch("logging.basicConfig", autospec=True) as mocked_config:
             cli.setup_logging(args)
         _, kwargs = mocked_config.call_args
-        self.assertEqual(kwargs["level"], "DEBUG")
+        assert kwargs["level"] == "DEBUG"
 
+    # XXX: we're now using pytest instead of unittest, so we may be able to
+    # clean up this test.
     def test_logging_formatter(self):
         # unittest has its grubby mits all over logging, making it difficult
         # to test log output without using unittest.TestCase.assertLogs().
@@ -496,33 +492,33 @@ def test_logging_formatter(self):
         formatted_info_str = fmt.format(logging.makeLogRecord(info_dict))
         formatted_warn_str = fmt.format(logging.makeLogRecord(warn_dict))
         formatted_warn_str2 = fmt.format(logging.makeLogRecord(warn_dict2))
-        self.assertEqual(formatted_debug_str, "DEBUG: " + debug_str)
-        self.assertEqual(formatted_info_str, "INFO: " + info_str)
-        self.assertEqual(formatted_warn_str, "WARNING: " + warn_str)
-        self.assertEqual(formatted_warn_str2, warn_str)
+        assert formatted_debug_str == "DEBUG: " + debug_str
+        assert formatted_info_str == "INFO: " + info_str
+        assert formatted_warn_str == "WARNING: " + warn_str
+        assert formatted_warn_str2 == warn_str
 
 
-class TestErrors(unittest.TestCase):
+class TestErrors:
 
     # Need to mock out setup_logging here or we spew logging to the console
     # in later tests.
     @mock.patch("stdpopsim.cli.setup_logging", autospec=True)
     def run_stdpopsim(self, command, mock_setup_logging):
         stdout, stderr = capture_output(cli.stdpopsim_main, command)
-        self.assertEqual(stderr, "")
-        self.assertEqual(stdout, "")
-        self.assertTrue(mock_setup_logging.called)
+        assert stderr == ""
+        assert stdout == ""
+        assert mock_setup_logging.called
 
     def test_exit(self):
         with mock.patch(
             "sys.exit", side_effect=ExceptionForTesting, autospec=True
         ) as mocked_exit:
-            with self.assertRaises(ExceptionForTesting):
+            with pytest.raises(ExceptionForTesting):
                 cli.exit("XXX")
             mocked_exit.assert_called_once()
             args = mocked_exit.call_args[0]
-            self.assertEqual(len(args), 1)
-            self.assertTrue(args[0].endswith("XXX"))
+            assert len(args) == 1
+            assert args[0].endswith("XXX")
 
     # Need to mock out setup_logging here or we spew logging to the console
     # in later tests.
@@ -531,7 +527,7 @@ def verify_bad_samples(self, cmd, mock_setup_logging):
         with mock.patch(
             "stdpopsim.cli.exit", side_effect=ExceptionForTesting, autospec=True
         ) as mocked_exit:
-            with self.assertRaises(ExceptionForTesting):
+            with pytest.raises(ExceptionForTesting):
                 cli.stdpopsim_main(cmd.split())
             mocked_exit.assert_called_once()
 
@@ -548,14 +544,14 @@ def test_browning_america(self):
         self.verify_bad_samples("HomSap -d AmericanAdmixture_4B11 2 3 4 5 6")
 
 
-class TestHelp(unittest.TestCase):
+class TestHelp:
     def run_stdpopsim(self, command):
         with mock.patch(
             "argparse.ArgumentParser.exit",
             side_effect=ExceptionForTesting,
             autospec=True,
         ) as mocked_exit:
-            with self.assertRaises(ExceptionForTesting):
+            with pytest.raises(ExceptionForTesting):
                 capture_output(cli.stdpopsim_main, command.split())
             mocked_exit.assert_called_once()
 
@@ -582,7 +578,7 @@ def test_all_species_genetic_maps_help(self):
             self.run_stdpopsim(f"{species} --help-genetic-maps")
 
 
-class TestWriteBibtex(unittest.TestCase):
+class TestWriteBibtex:
     """
     Test that citations are able to be converted to bibtex
     and written to file."""
@@ -638,41 +634,39 @@ def test_number_of_calls(self):
                     stdpopsim.citations.Citation, "fetch_bibtex", autospec=True
                 ) as mock_bib:
                     cli.write_bibtex(engine, model, contig, species, bib)
-                    self.assertEqual(mock_bib.call_count, ncite)
+                    assert mock_bib.call_count == ncite
 
 
-class TestWriteCitations(unittest.TestCase):
+class TestWriteCitations:
     """
     Make sure citation information is written.
     """
 
-    def test_model_citations(self):
-        contig = stdpopsim.Contig()
+    @pytest.mark.usefixtures("caplog")
+    def test_model_citations(self, caplog):
         species = stdpopsim.get_species("HomSap")
+        contig = species.get_contig("22")
         model = species.get_demographic_model("OutOfAfrica_3G09")
         engine = stdpopsim.get_default_engine()
-        with self.assertLogs() as logs:
-            stdout, stderr = capture_output(
-                cli.write_citations, engine, model, contig, species
-            )
-        self.assertEqual(len(stdout), 0)
+        stdout, stderr = capture_output(
+            cli.write_citations, engine, model, contig, species
+        )
+        assert len(stdout) == 0
         genetic_map = None
-        output = "\n".join(logs.output)
-        self.check_citations(engine, species, genetic_map, model, output)
+        self.check_citations(engine, species, genetic_map, model, caplog.text)
 
-    def test_genetic_map_citations(self):
+    @pytest.mark.usefixtures("caplog")
+    def test_genetic_map_citations(self, caplog):
         species = stdpopsim.get_species("HomSap")
         genetic_map = species.get_genetic_map("HapMapII_GRCh37")
         contig = species.get_contig("chr20", genetic_map=genetic_map.id)
         model = stdpopsim.PiecewiseConstantSize(species.population_size)
         engine = stdpopsim.get_default_engine()
-        with self.assertLogs() as logs:
-            stdout, stderr = capture_output(
-                cli.write_citations, engine, model, contig, species
-            )
-        self.assertEqual(len(stdout), 0)
-        output = "\n".join(logs.output)
-        self.check_citations(engine, species, genetic_map, model, output)
+        stdout, stderr = capture_output(
+            cli.write_citations, engine, model, contig, species
+        )
+        assert len(stdout) == 0
+        self.check_citations(engine, species, genetic_map, model, caplog.text)
 
     def check_citations(self, engine, species, genetic_map, model, output):
         if genetic_map is None:
@@ -692,12 +686,12 @@ def check_citations(self, engine, species, genetic_map, model, output):
             ),
         ):
             for citation in citations:
-                self.assertTrue(citation.author in output, msg=assert_msg)
-                self.assertTrue(str(citation.year) in output, msg=assert_msg)
-                self.assertTrue(citation.doi in output, msg=assert_msg)
+                assert citation.author in output, assert_msg
+                assert str(citation.year) in output, assert_msg
+                assert citation.doi in output, assert_msg
 
 
-class TestCacheDir(unittest.TestCase):
+class TestCacheDir:
     """
     Tests for setting the cache directory.
     """
@@ -706,8 +700,8 @@ class TestCacheDir(unittest.TestCase):
     @mock.patch("stdpopsim.cli.run", autospec=True)
     def run_stdpopsim(self, command, mock_setup_logging, mock_run):
         stdout, stderr = capture_output(cli.stdpopsim_main, command)
-        self.assertEqual(stderr, "")
-        self.assertEqual(stdout, "")
+        assert stderr == ""
+        assert stdout == ""
         mock_setup_logging.assert_called_once()
         mock_run.assert_called_once()
 
@@ -734,7 +728,7 @@ def test_download_genetic_maps(self):
         self.check_cache_dir_set(cmd, cache_dir)
 
 
-class TestDownloadGeneticMaps(unittest.TestCase):
+class TestDownloadGeneticMaps:
     """
     Tests for the download genetic maps function.
     """
@@ -746,17 +740,17 @@ def run_download(self, cmd_args, expected_num_downloads):
             "stdpopsim.GeneticMap.download", autospec=True
         ) as mocked_download:
             cli.run_download_genetic_maps(args)
-            self.assertEqual(mocked_download.call_count, expected_num_downloads)
+            assert mocked_download.call_count == expected_num_downloads
 
     def test_defaults(self):
         num_maps = sum(len(species.genetic_maps) for species in stdpopsim.all_species())
-        self.assertGreater(num_maps, 0)
+        assert num_maps > 0
         self.run_download("", num_maps)
 
     def test_homsap_defaults(self):
         species = stdpopsim.get_species("HomSap")
         num_maps = len(species.genetic_maps)
-        self.assertGreater(num_maps, 0)
+        assert num_maps > 0
         self.run_download("HomSap", num_maps)
 
     def test_homsap_specify_maps(self):
@@ -767,7 +761,7 @@ def test_homsap_specify_maps(self):
             self.run_download("HomSap " + args, j + 1)
 
 
-class TestSearchWrappers(unittest.TestCase):
+class TestSearchWrappers:
     """
     Tests that the search wrappers for species etc work correctly.
     """
@@ -794,7 +788,7 @@ def test_bad_genetic_map(self):
             )
 
 
-class TestDryRun(unittest.TestCase):
+class TestDryRun:
     """
     Checks that simulations we run from the CLI with the --dry-run option have no output
     """
@@ -806,8 +800,8 @@ def test_dry_run(self):
                 filename = path / "output.trees"
                 cmd = f"{sys.executable} -m stdpopsim HomSap -D -L 1000 -o {filename} 2"
                 subprocess.run(cmd, stderr=stderr, shell=True, check=True)
-                self.assertGreater(stderr.tell(), 0)
-            self.assertFalse(os.path.isfile(filename))
+                assert stderr.tell() > 0
+            assert not os.path.isfile(filename)
 
     def test_dry_run_quiet(self):
         with tempfile.TemporaryDirectory() as tmpdir:
@@ -819,11 +813,11 @@ def test_dry_run_quiet(self):
                     "-o {filename} 2"
                 )
                 subprocess.run(cmd, stderr=stderr, shell=True, check=True)
-                self.assertEqual(stderr.tell(), 0)
-            self.assertFalse(os.path.isfile(filename))
+                assert stderr.tell() == 0
+            assert not os.path.isfile(filename)
 
 
-class TestMsprimeEngine(unittest.TestCase):
+class TestMsprimeEngine:
     def docmd(self, _cmd):
         with tempfile.TemporaryDirectory() as tmpdir:
             filename = pathlib.Path(tmpdir) / "output.trees"
@@ -846,15 +840,15 @@ def test_simulate(self):
         )
 
     def test_invalid_CLI_parameters(self):
-        with self.assertRaises(SystemExit):
+        with pytest.raises(SystemExit):
             self.docmd("--msprime-model notamodel")
-        with self.assertRaises(SystemExit):
+        with pytest.raises(SystemExit):
             self.docmd("--msprime-model dtwf --msprime-change-model 50 notamodel")
-        with self.assertRaises(SystemExit):
+        with pytest.raises(SystemExit):
             self.docmd("--msprime-model dtwf --msprime-change-model notanumber hudson")
-        with self.assertRaises(SystemExit):
+        with pytest.raises(SystemExit):
             self.docmd("--msprime-model hudson --msprime-change-model dtwf")
-        with self.assertRaises(SystemExit):
+        with pytest.raises(SystemExit):
             self.docmd("--msprime-model hudson --msprime-change-model 10")
 
     def test_invalid_API_parameters(self):
@@ -863,9 +857,9 @@ def test_invalid_API_parameters(self):
         contig = species.get_contig("chr20")
         model = species.get_demographic_model("OutOfAfrica_2T12")
         samples = model.get_samples(10)
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             engine.simulate(model, contig, samples, msprime_model="notamodel")
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             engine.simulate(
                 model,
                 contig,
@@ -876,16 +870,18 @@ def test_invalid_API_parameters(self):
             )
 
 
-class TestNonAutosomal(unittest.TestCase):
+class TestNonAutosomal:
     # TODO: This test should be removed when #383 is fixed.
     # https://github.com/popsim-consortium/stdpopsim/issues/383
     def test_chrX_gives_a_warning(self):
         cmd = "HomSap -D -c chrX -o /dev/null 10".split()
-        with self.assertWarns(stdpopsim.NonAutosomalWarning):
-            capture_output(stdpopsim.cli.stdpopsim_main, cmd)
+        # setup_logging() interferes with pytest.warns().
+        with mock.patch("stdpopsim.cli.setup_logging", autospec=True):
+            with pytest.warns(stdpopsim.NonAutosomalWarning):
+                capture_output(stdpopsim.cli.stdpopsim_main, cmd)
 
 
-class TestNoQCWarning(unittest.TestCase):
+class TestNoQCWarning:
     species = stdpopsim.get_species("EscCol")
     model = stdpopsim.DemographicModel(
         id="FakeModel",
@@ -904,15 +900,19 @@ class TestNoQCWarning(unittest.TestCase):
         population_configurations=[msprime.PopulationConfiguration(initial_size=1000)],
     )
 
-    def setUp(self):
-        self.species.add_demographic_model(self.model)
+    @classmethod
+    def setup_class(cls):
+        cls.species.add_demographic_model(cls.model)
 
-    def tearDown(self):
-        self.species.demographic_models.remove(self.model)
+    @classmethod
+    def teardown_class(cls):
+        cls.species.demographic_models.remove(cls.model)
 
     def verify_noQC_warning(self, cmd):
-        with pytest.warns(stdpopsim.QCMissingWarning):
-            capture_output(stdpopsim.cli.stdpopsim_main, cmd.split())
+        # setup_logging() interferes with pytest.warns().
+        with mock.patch("stdpopsim.cli.setup_logging", autospec=True):
+            with pytest.warns(stdpopsim.QCMissingWarning):
+                capture_output(stdpopsim.cli.stdpopsim_main, cmd.split())
 
     def test_noQC_warning(self):
         self.verify_noQC_warning("EscCol -d FakeModel -D 10 -L 10")
@@ -920,27 +920,36 @@ def test_noQC_warning(self):
     def test_noQC_warning_quiet(self):
         self.verify_noQC_warning("-q EscCol -d FakeModel -D 10 -L 10")
 
-    def verify_noQC_citations_not_written(self, cmd):
+    def verify_noQC_citations_not_written(self, cmd, caplog):
         # Non-QCed models shouldn't be used in publications, so citations
         # shouldn't be offered to the user.
-        with self.assertLogs() as logs:
-            with warnings.catch_warnings():
-                warnings.simplefilter("ignore")
-                out, err = capture_output(stdpopsim.cli.stdpopsim_main, cmd.split())
-        log_output = "\n".join(logs.output)
+        out, err = capture_output(stdpopsim.cli.stdpopsim_main, cmd.split())
+        log_output = caplog.text
         for citation in self.model.citations:
-            self.assertFalse(citation.author in out)
-            self.assertFalse(citation.doi in out)
-            self.assertFalse(citation.author in err)
-            self.assertFalse(citation.doi in err)
-            self.assertFalse(citation.author in log_output)
-            self.assertFalse(citation.doi in log_output)
-
-    def test_noQC_citations_not_written(self):
-        self.verify_noQC_citations_not_written("EscCol -d FakeModel -D 10 -L 10")
-
-    def test_noQC_citations_not_written_verbose(self):
-        self.verify_noQC_citations_not_written("-vv EscCol -d FakeModel -D 10 -L 10")
+            assert not (citation.author in out)
+            assert not (citation.doi in out)
+            assert not (citation.author in err)
+            assert not (citation.doi in err)
+            assert not (citation.author in log_output)
+            assert not (citation.doi in log_output)
+
+    # The following two tests use the "caplog" pytest fixture, which captures
+    # the logging output. The caplog param is automatically passed to test_*()
+    # methods by pytest, which we pass through to verify_noQC_citations_not_written().
+
+    @pytest.mark.filterwarnings("ignore::stdpopsim.QCMissingWarning")
+    @pytest.mark.usefixtures("caplog")
+    def test_noQC_citations_not_written(self, caplog):
+        self.verify_noQC_citations_not_written(
+            "EscCol -d FakeModel -D 10 -L 10", caplog
+        )
+
+    @pytest.mark.filterwarnings("ignore::stdpopsim.QCMissingWarning")
+    @pytest.mark.usefixtures("caplog")
+    def test_noQC_citations_not_written_verbose(self, caplog):
+        self.verify_noQC_citations_not_written(
+            "-vv EscCol -d FakeModel -D 10 -L 10", caplog
+        )
 
 
 @pytest.mark.parametrize(
diff --git a/tests/test_engines.py b/tests/test_engines.py
index 26b01ba6e..82335bd4e 100644
--- a/tests/test_engines.py
+++ b/tests/test_engines.py
@@ -1,22 +1,21 @@
 """
 Tests for simulation engine infrastructure.
 """
-import unittest
-
 import stdpopsim
 import msprime
+import pytest
 
 
-class TestEngineAPI(unittest.TestCase):
+class TestEngineAPI:
     """
     Tests for the API exposed for simulation engines.
     """
 
     def _test_engine(self, engine):
-        self.assertIsInstance(engine, stdpopsim.Engine)
-        self.assertIsNotNone(engine.simulate)
-        self.assertNotEqual(len(engine.citations), 0)
-        self.assertNotEqual(len(engine.get_version()), 0)
+        assert isinstance(engine, stdpopsim.Engine)
+        assert engine.simulate is not None
+        assert len(engine.citations) != 0
+        assert len(engine.get_version()) != 0
 
     def test_get_default_engine(self):
         engine = stdpopsim.get_default_engine()
@@ -35,27 +34,31 @@ class MyEngine(stdpopsim.Engine):
         engine1 = MyEngine()
         stdpopsim.register_engine(engine1)
         engine2 = stdpopsim.get_engine(engine1.id)
-        self.assertEqual(engine1, engine2)
+        assert engine1 == engine2
         # remove engine to avoid possible problems with other tests
         del stdpopsim.engines._registered_engines[engine1.id]
 
     def test_register_duplicate(self):
         engine = stdpopsim.get_default_engine()
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             stdpopsim.register_engine(engine)
 
     def test_get_engine(self):
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             stdpopsim.get_engine("nonexistent")
 
     def test_abstract_base_class(self):
         e = stdpopsim.Engine()
-        with self.assertRaises(NotImplementedError):
+        with pytest.raises(NotImplementedError):
             e.simulate(None, None, None)
-        self.assertRaises(NotImplementedError, e.get_version)
+        with pytest.raises(NotImplementedError):
+            e.get_version()
 
 
-class TestBehaviour(unittest.TestCase):
+@pytest.mark.filterwarnings(
+    "ignore:.*model has mutation rate.*but this simulation used.*"
+)
+class TestBehaviour:
     def test_simulate_nonexistent_param(self):
         species = stdpopsim.get_species("HomSap")
         model = species.get_demographic_model("AshkSub_7G19")
@@ -68,7 +71,7 @@ def test_simulate_nonexistent_param(self):
         bad_kwargs = good_kwargs.copy().update(nonexistent_param=None)
         for engine in stdpopsim.all_engines():
             engine.simulate(**good_kwargs)
-            with self.assertRaises(TypeError):
+            with pytest.raises(TypeError):
                 engine.simulate(**bad_kwargs)
 
     def test_required_params(self):
@@ -76,7 +79,7 @@ def test_required_params(self):
         model = species.get_demographic_model("AshkSub_7G19")
         contig = (species.get_contig("chr1"),)
         for engine in stdpopsim.all_engines():
-            with self.assertRaises(TypeError):
+            with pytest.raises(TypeError):
                 engine.simulate(model, contig)
 
     def test_msprime_kwargs(self):
@@ -96,8 +99,8 @@ def test_msprime_seed(self):
         contig = species.get_contig("chr22", length_multiplier=0.01)
         samples = model.get_samples(10)
         engine = stdpopsim.get_engine("msprime")
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             engine.simulate(model, contig, samples, seed=1, random_seed=1)
         sim_seed = engine.simulate(model, contig, samples, seed=1)
         sim_random_seed = engine.simulate(model, contig, samples, random_seed=1)
-        self.assertEquals(sim_seed.tables.edges, sim_random_seed.tables.edges)
+        assert sim_seed.tables.edges == sim_random_seed.tables.edges
diff --git a/tests/test_genetic_maps.py b/tests/test_genetic_maps.py
index 82ede7a18..0b3c7331f 100644
--- a/tests/test_genetic_maps.py
+++ b/tests/test_genetic_maps.py
@@ -1,16 +1,14 @@
 """
 Tests for the genetic maps management.
 """
-import unittest
 from unittest import mock
 import tarfile
 import tempfile
 import os.path
 import pathlib
-import pytest
-import re
 
 import msprime
+import pytest
 
 import stdpopsim
 from stdpopsim import utils
@@ -27,7 +25,7 @@
 saved_urls = {}
 
 
-def setUpModule():
+def setup_module():
     destination = pathlib.Path("_test_cache/tarballs")
     for genetic_map in stdpopsim.all_genetic_maps():
         key = genetic_map.id
@@ -50,7 +48,7 @@ def setUpModule():
         genetic_map._cache.url = genetic_map.url
 
 
-def tearDownModule():
+def teardown_module():
     for genetic_map in stdpopsim.all_genetic_maps():
         genetic_map.url = saved_urls[genetic_map.id]
         genetic_map._cache.url = genetic_map.url
@@ -107,7 +105,7 @@ def get_genetic_map_tarball():
     return tarball
 
 
-class TestGeneticMapTarball(unittest.TestCase):
+class TestGeneticMapTarball:
     """
     Tests that we correctly encode a genetic map in the tarball test function.
     """
@@ -128,7 +126,7 @@ def get_maps(self, tarball):
     def test_no_args(self):
         tarball = get_genetic_map_tarball()
         maps = self.get_maps(tarball)
-        self.assertGreater(len(maps), 0)
+        assert len(maps) > 0
 
 
 class TestGeneticMap(tests.CacheWritingTest):
@@ -139,11 +137,11 @@ class TestGeneticMap(tests.CacheWritingTest):
     def test_cache_dirs(self):
         gm = GeneticMapTestClass()
         cache_dir = stdpopsim.get_cache_dir() / "genetic_maps" / gm.species.id / gm.id
-        self.assertEqual(gm.map_cache_dir, cache_dir)
+        assert gm.map_cache_dir == cache_dir
 
     def test_str(self):
         gm = GeneticMapTestClass()
-        self.assertGreater(len(str(gm)), 0)
+        assert len(str(gm)) > 0
 
 
 class TestGeneticMapDownload(tests.CacheWritingTest):
@@ -155,17 +153,17 @@ def test_correct_url(self):
         gm = GeneticMapTestClass()
         with mock.patch("stdpopsim.utils.download", autospec=True) as mocked_get:
             # The destination file will be missing.
-            with self.assertRaises(FileNotFoundError):
+            with pytest.raises(FileNotFoundError):
                 gm.download()
-        mocked_get.assert_called_once_with(gm.url, unittest.mock.ANY)
+        mocked_get.assert_called_once_with(gm.url, mock.ANY)
 
     def test_download_over_cache(self):
         species = stdpopsim.get_species("DroMel")
         gm = species.get_genetic_map("ComeronCrossover_dm6")
         gm.download()
-        self.assertTrue(gm.is_cached())
+        assert gm.is_cached()
         gm.download()
-        self.assertTrue(gm.is_cached())
+        assert gm.is_cached()
 
     def test_multiple_threads_downloading(self):
         species = stdpopsim.get_species("DroMel")
@@ -176,7 +174,7 @@ def test_multiple_threads_downloading(self):
             # Trick the download code into thinking there's several happening
             # concurrently
             gm._cache.is_cached = lambda: False
-            with self.assertWarns(UserWarning):
+            with pytest.warns(UserWarning, match="multiple processes downloading"):
                 gm.download()
         finally:
             gm._cache.is_cached = saved
@@ -188,16 +186,16 @@ class TestAllGeneticMaps(tests.CacheReadingTest):
     """
 
     def test_non_empty(self):
-        self.assertGreater(len(list(stdpopsim.all_genetic_maps())), 0)
+        assert len(list(stdpopsim.all_genetic_maps())) > 0
 
     def test_types(self):
         for gm in stdpopsim.all_genetic_maps():
-            self.assertIsInstance(gm, stdpopsim.GeneticMap)
+            assert isinstance(gm, stdpopsim.GeneticMap)
 
     def test_ids(self):
         for gm in stdpopsim.all_genetic_maps():
-            self.assertIsInstance(gm.id, str)
-            self.assertTrue(utils.is_valid_genetic_map_id(gm.id))
+            assert isinstance(gm.id, str)
+            assert utils.is_valid_genetic_map_id(gm.id)
 
 
 class TestGetChromosomeMap(tests.CacheReadingTest):
@@ -209,38 +207,41 @@ def test_warning_from_no_mapped_chromosome(self):
         species = stdpopsim.get_species("HomSap")
         genetic_map = species.get_genetic_map("HapMapII_GRCh37")
         chrom = species.genome.get_chromosome("chrY")
-        with self.assertWarns(Warning):
+        with pytest.warns(UserWarning, match="Recombination map not found"):
             cm = genetic_map.get_chromosome_map(chrom.id)
-        self.assertIsInstance(cm, msprime.RateMap)
-        self.assertEqual(chrom.length, cm.sequence_length)
+        assert isinstance(cm, msprime.RateMap)
+        assert chrom.length == cm.sequence_length
 
     def test_known_chromosome(self):
         species = stdpopsim.get_species("CanFam")
         genetic_map = species.get_genetic_map("Campbell2016_CanFam3_1")
         chrom = species.genome.get_chromosome("1")
         cm = genetic_map.get_chromosome_map(chrom.id)
-        self.assertIsInstance(cm, msprime.RateMap)
-        self.assertEqual(chrom.length, cm.sequence_length)
+        assert isinstance(cm, msprime.RateMap)
+        assert chrom.length == cm.sequence_length
 
     def test_warning_for_long_recomb_map(self):
         species = stdpopsim.get_species("HomSap")
         genetic_map = species.get_genetic_map("HapMapII_GRCh37")
         chrom = species.genome.get_chromosome("chr1")
-        with self.assertWarns(Warning):
+        with pytest.warns(
+            UserWarning, match="Recombination map.*is longer than chromosome length"
+        ):
             cm = genetic_map.get_chromosome_map(chrom.id)
-        self.assertIsInstance(cm, msprime.RateMap)
-        self.assertLess(chrom.length, cm.sequence_length)
+        assert isinstance(cm, msprime.RateMap)
+        assert chrom.length < cm.sequence_length
 
     def test_unknown_chromosome(self):
         species = stdpopsim.get_species("HomSap")
         genetic_map = species.get_genetic_map("HapMapII_GRCh37")
         for bad_chrom in ["", "ABD", None]:
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 genetic_map.get_chromosome_map(bad_chrom)
 
     @pytest.mark.filterwarnings(
         "ignore: Recombination map.*is longer than chromosome length"
     )
+    @pytest.mark.filterwarnings("error: Recombination map not found")
     def test_one_chrom_from_each_map(self):
         for gm in stdpopsim.all_genetic_maps():
             species = gm.species
@@ -252,18 +253,7 @@ def test_one_chrom_from_each_map(self):
             # There's no requirement that any given chromosome is actually
             # in the map, and we don't have a direct way to check
             # for its presence. But if this chromsome is *not* in the
-            # map, we will recieve a warning (and then fail the test below).
+            # map, we will recieve a warning (which is treated as an error
+            # using the warnings filter).
             chrom = species.genome.chromosomes[0]
-            with pytest.warns(None) as record:
-                gm.get_chromosome_map(chrom.id)
-
-            # Fail the test if we get any warnings matching the message below.
-            # There doesn't seem to be a way to simply check for the absense
-            # of a specific warning using pytest, so we record all warnings
-            # and check manually.
-            record = [
-                r
-                for r in record
-                if re.match(r"Recombination map not found", str(r.message)) is not None
-            ]
-            assert len(record) == 0, f"{species.id} / {gm.id}"
+            gm.get_chromosome_map(chrom.id)
diff --git a/tests/test_genomes.py b/tests/test_genomes.py
index 50fbcfc3f..4812587ea 100644
--- a/tests/test_genomes.py
+++ b/tests/test_genomes.py
@@ -1,18 +1,19 @@
 """
 Tests for classes that hold information about genomic region to be simulated.
 """
-import unittest
-import stdpopsim
 import numpy as np
+import pytest
+
+import stdpopsim
 
 
-class TestGenomicElementType(unittest.TestCase):
+class TestGenomicElementType:
     def test_bad_proportions(self):
         # no proportion but mut type, prop > 1, sum(prop) > 1
         proportions = ([], [2], [0.5, 0.5, 0.5])
         mut_type_ids = ([0], [0], [0])
         for props in proportions:
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 stdpopsim.GenomicElementType(
                     intervals=np.array([[0, 1]]),
                     mutation_type_ids=mut_type_ids,
@@ -20,16 +21,10 @@ def test_bad_proportions(self):
                 )
 
 
-class TestContig(unittest.TestCase):
-    def get_test_contig(self):
-        species = stdpopsim.get_species("HomSap")
-        contig = species.get_contig("chr22", length_multiplier=0.001)
-        return contig
-
+class TestContig:
     def test_all_intervals_array(self):
-        contig = self.get_test_contig()
-        contig.fully_neutral()
-        self.assertTrue(len(contig.all_intervals_array) == 1)
+        contig = stdpopsim.Contig.basic_contig(length=100)
+        assert len(contig.all_intervals_array) == 1
 
         truth = np.array([[20, 30, 1], [30, 50, 0]])
         contig.clear_genomic_mutation_types()
@@ -43,47 +38,37 @@ def test_all_intervals_array(self):
             mutation_types=[stdpopsim.ext.MutationType()],
             proportions=np.array([0]),
         )
-        self.assertTrue((contig.all_intervals_array == truth).all())
-
-    def test_fully_neutral_error(self):
-        contig = self.get_test_contig()
-        contig.recombination_map = None
-        with self.assertRaises(ValueError):
-            contig.fully_neutral()
+        assert (contig.all_intervals_array == truth).all()
 
     def test_add_mutation_type_errors(self):
-        contig = self.get_test_contig()
+        contig = stdpopsim.Contig.basic_contig(length=100)
         # diff length mutation types and proportions
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             contig.add_mutation_types([stdpopsim.ext.MutationType()], [], 0)
         # invalid ge type id
-        with self.assertRaises(ValueError):
-            contig.add_mutation_types([stdpopsim.ext.MutationType()], [0], 0)
+        with pytest.raises(ValueError):
+            contig.add_mutation_types([stdpopsim.ext.MutationType()], [0], 1)
         contig.fully_neutral()
         # sum props > 1
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             contig.add_mutation_types([stdpopsim.ext.MutationType()], [1.1], 0)
 
     def test_add_genomic_element_type_errors(self):
-        contig = self.get_test_contig()
-        contig.recombination_map = None
-        # can't add ge type without rec map
-        with self.assertRaises(ValueError):
-            contig.add_genomic_element_type(np.array([]), [], [])
-        contig = self.get_test_contig()
+        contig = stdpopsim.Contig.basic_contig(length=100)
         # bad intervals
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             contig.add_genomic_element_type(np.array([10, 20]), [], [])
 
     def test_add_genomic_element_type(self):
-        contig = self.get_test_contig()
-        self.assertTrue(contig.mutation_types == [])
-        self.assertTrue(contig.genomic_element_types == [])
+        contig = stdpopsim.Contig.basic_contig(length=100)
+        contig.clear_genomic_mutation_types()
+        assert contig.mutation_types == []
+        assert contig.genomic_element_types == []
         intervals1 = np.array([[10, 20], [20, 40], [100, 200]])
         intervals2 = np.array([[5, 10], [45, 50], [200, 250]])
         m1 = stdpopsim.ext.MutationType()
         m2 = stdpopsim.ext.MutationType()
         contig.add_genomic_element_type(intervals1, [m1, m2], [0.5, 0.3])
         contig.add_genomic_element_type(intervals2, [m1], [0.2])
-        self.assertTrue(len(contig.genomic_element_types) == 2)
-        self.assertTrue(len(contig.mutation_types) == 2)
+        assert len(contig.genomic_element_types) == 2
+        assert len(contig.mutation_types) == 2
diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py
index 416d430f7..103d951b9 100644
--- a/tests/test_maintenance.py
+++ b/tests/test_maintenance.py
@@ -1,7 +1,6 @@
 """
 Tests for the maintenance utilities.
 """
-import unittest
 from unittest import mock
 import json
 import urllib
@@ -19,41 +18,41 @@ def read(self):
         return json.dumps(self.value)
 
 
-class TestEnsemblClient(unittest.TestCase):
+class TestEnsemblClient:
     """
     Tests for the Ensembl rest client.
     """
 
     def test_defaults(self):
         client = maint.EnsemblRestClient()
-        self.assertEqual(client.server, "http://rest.ensembl.org")
-        self.assertEqual(client.max_requests_per_second, 15)
+        assert client.server == "http://rest.ensembl.org"
+        assert client.max_requests_per_second == 15
 
     def test_request_params(self):
         client = maint.EnsemblRestClient("http://example.org")
         request = client._make_request("a/b", params={"a": "b"})
-        self.assertEqual(request.full_url, "http://example.org/a/b?a=b")
+        assert request.full_url == "http://example.org/a/b?a=b"
         request = client._make_request("", params={"a": 0, "b": 1})
-        self.assertEqual(request.full_url, "http://example.org?a=0&b=1")
+        assert request.full_url == "http://example.org?a=0&b=1"
         request = client._make_request("a b", params={"a": "a b"})
-        self.assertEqual(request.full_url, "http://example.org/a%20b?a=a+b")
+        assert request.full_url == "http://example.org/a%20b?a=a+b"
 
     def test_basic_example(self):
         test_server = "http://example.com"
         client = maint.EnsemblRestClient(test_server)
-        self.assertEqual(client.server, test_server)
+        assert client.server == test_server
 
         returned_response = MockedResponse()
         with mock.patch(
             "urllib.request.urlopen", autospec=True, return_value=returned_response
         ) as mocked_open:
             value = client.get("test_endpoint")
-            self.assertEqual(value, {})
+            assert value == {}
             mocked_open.assert_called_once()
             request_sent = mocked_open.call_args[0][0]
-            self.assertIsInstance(request_sent, urllib.request.Request)
-            self.assertEqual(request_sent.full_url, test_server + "/test_endpoint")
-            self.assertEqual(request_sent.headers, {"Content-type": "Application/json"})
+            assert isinstance(request_sent, urllib.request.Request)
+            assert request_sent.full_url == test_server + "/test_endpoint"
+            assert request_sent.headers == {"Content-type": "Application/json"}
 
     def test_extra_headers(self):
         test_server = "http://example.com"
@@ -66,34 +65,34 @@ def test_extra_headers(self):
             extra_headers = {"A": "sdf", "B": "xyz"}
             value = client.get("/test_endpoint", extra_headers)
             # We don't modify the input
-            self.assertEqual(len(extra_headers), 2)
-            self.assertEqual(value, {})
+            assert len(extra_headers) == 2
+            assert value == {}
             mocked_open.assert_called_once()
             request_sent = mocked_open.call_args[0][0]
-            self.assertIsInstance(request_sent, urllib.request.Request)
-            self.assertEqual(request_sent.full_url, test_server + "/test_endpoint")
+            assert isinstance(request_sent, urllib.request.Request)
+            assert request_sent.full_url == test_server + "/test_endpoint"
             extra_headers["Content-type"] = "Application/json"
-            self.assertEqual(request_sent.headers, extra_headers)
+            assert request_sent.headers == extra_headers
 
     def test_rate_limit(self):
         client = maint.EnsemblRestClient(max_requests_per_second=1)
         with mock.patch("time.sleep", autospec=True) as mocked_sleep:
             client._sleep_if_needed()
-            self.assertEqual(mocked_sleep.call_count, 0)
+            assert mocked_sleep.call_count == 0
             client._sleep_if_needed()
-            self.assertEqual(mocked_sleep.call_count, 1)
+            assert mocked_sleep.call_count == 1
             client._sleep_if_needed()
-            self.assertEqual(mocked_sleep.call_count, 1)
+            assert mocked_sleep.call_count == 1
             client._sleep_if_needed()
-            self.assertEqual(mocked_sleep.call_count, 2)
+            assert mocked_sleep.call_count == 2
 
         client = maint.EnsemblRestClient(max_requests_per_second=3)
         with mock.patch("time.sleep", autospec=True) as mocked_sleep:
             client._sleep_if_needed()
-            self.assertEqual(mocked_sleep.call_count, 0)
+            assert mocked_sleep.call_count == 0
             client._sleep_if_needed()
-            self.assertEqual(mocked_sleep.call_count, 0)
+            assert mocked_sleep.call_count == 0
             client._sleep_if_needed()
-            self.assertEqual(mocked_sleep.call_count, 0)
+            assert mocked_sleep.call_count == 0
             client._sleep_if_needed()
-            self.assertEqual(mocked_sleep.call_count, 1)
+            assert mocked_sleep.call_count == 1
diff --git a/tests/test_masking.py b/tests/test_masking.py
index 45cd02cfe..9476b1991 100644
--- a/tests/test_masking.py
+++ b/tests/test_masking.py
@@ -1,18 +1,19 @@
 """
 Tests for the genetic maps management.
 """
-import unittest
-
-import stdpopsim.utils
-import msprime
-import numpy as np
 import os
 import sys
 
+import numpy as np
+import msprime
+import pytest
+
+import stdpopsim.utils
+
 IS_WINDOWS = sys.platform.startswith("win")
 
 
-class TestMasking(unittest.TestCase):
+class TestMasking:
     def test_load_intervals(self):
         intervals_in = {
             "chr1": [(10, 10000)],
@@ -26,15 +27,15 @@ def test_load_intervals(self):
         intervals_chr22 = stdpopsim.utils.read_bed("temp_file.bed", "chr22")
         os.remove("temp_file.bed")
         for interval in intervals_chr1:
-            self.assertTrue(tuple(interval) in intervals_in["chr1"])
+            assert tuple(interval) in intervals_in["chr1"]
         for interval in intervals_chr22:
-            self.assertTrue(tuple(interval) in intervals_in["chr22"])
-        self.assertTrue(len(intervals_chr1) == len(intervals_in["chr1"]))
-        self.assertTrue(len(intervals_chr22) == len(intervals_in["chr22"]))
+            assert tuple(interval) in intervals_in["chr22"]
+        assert len(intervals_chr1) == len(intervals_in["chr1"])
+        assert len(intervals_chr22) == len(intervals_in["chr22"])
 
     def test_length_interval_invalid(self):
         species = stdpopsim.get_species("HomSap")
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             contig = species.get_contig(
                 "chr22", length_multiplier=0.1, inclusion_mask="test.bed"
             )
@@ -59,23 +60,23 @@ def intervals_to_keep_test_func(mask_fpath, chrom):
         intervals_utils = stdpopsim.utils.read_bed("temp_file.bed", "chr1")
         os.remove("temp_file.bed")
         for i1, i2 in zip(intervals_utils, intervals_test):
-            self.assertTrue(i1[0] == i2[0])
-            self.assertTrue(i1[1] == i2[1])
+            assert i1[0] == i2[0]
+            assert i1[1] == i2[1]
 
     def test_mask_from_intervals(self):
         species = stdpopsim.get_species("HomSap")
         contig = species.get_contig("chr22", exclusion_mask=[(0, 16.5e6)])
-        self.assertTrue(contig.inclusion_mask is None)
-        self.assertTrue(contig.exclusion_mask[0][0] == 0)
-        self.assertTrue(contig.exclusion_mask[0][1] == 16.5e6)
+        assert contig.inclusion_mask is None
+        assert contig.exclusion_mask[0][0] == 0
+        assert contig.exclusion_mask[0][1] == 16.5e6
         contig = species.get_contig("chr22", inclusion_mask=[(16.5e6, 45e6)])
-        self.assertTrue(contig.inclusion_mask[0][0] == 16.5e6)
-        self.assertTrue(contig.inclusion_mask[0][1] == 45e6)
-        self.assertTrue(contig.exclusion_mask is None)
+        assert contig.inclusion_mask[0][0] == 16.5e6
+        assert contig.inclusion_mask[0][1] == 45e6
+        assert contig.exclusion_mask is None
 
     def test_multiple_masks(self):
         species = stdpopsim.get_species("HomSap")
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             species.get_contig(
                 "chr22", exclusion_mask=[(0, 16.5e6)], inclusion_mask=[(16.5e6, 50e6)]
             )
@@ -88,11 +89,11 @@ def test_read_masks_from_bed(self):
                     fout.write(f"{c}\t{l}\t{r}\n")
         species = stdpopsim.get_species("HomSap")
         contig = species.get_contig("chr1", inclusion_mask="temp_file.bed")
-        self.assertTrue(contig.exclusion_mask is None)
-        self.assertTrue(len(contig.inclusion_mask) == 4)
+        assert contig.exclusion_mask is None
+        assert len(contig.inclusion_mask) == 4
         contig = species.get_contig("chr1", exclusion_mask="temp_file.bed")
-        self.assertTrue(contig.inclusion_mask is None)
-        self.assertTrue(contig.exclusion_mask[1][0] == 100)
+        assert contig.inclusion_mask is None
+        assert contig.exclusion_mask[1][0] == 100
         os.remove("temp_file.bed")
 
     def test_mask_tree_sequence(self):
@@ -103,22 +104,23 @@ def test_mask_tree_sequence(self):
         exclude = False
         ts_mask = stdpopsim.utils.mask_tree_sequence(ts, intervals, exclude)
         # check we get the right number of trees (simulated with no recomb)
-        self.assertTrue(ts_mask.num_trees == len(intervals) * 2 - 1)
+        assert ts_mask.num_trees == len(intervals) * 2 - 1
 
         exclude = True
         intervals = np.array([[0, 100], [200, 1000]])
         ts_mask = stdpopsim.utils.mask_tree_sequence(ts, intervals, exclude)
         # check we get the right number of trees (simulated with no recomb)
-        self.assertTrue(ts_mask.num_trees == len(intervals) * 2 - 1)
+        assert ts_mask.num_trees == len(intervals) * 2 - 1
         # check that positions of mutations in ts_mask are within mask
         positions = np.array(
             [ts_mask.site(m.site).position for m in ts_mask.mutations()]
         )
-        self.assertTrue(np.all(np.logical_and(100 <= positions, positions < 200)))
+        assert np.all(np.logical_and(100 <= positions, positions < 200))
 
 
-@unittest.skipIf(IS_WINDOWS, "SLiM not available on windows")
-class TestSimulate(unittest.TestCase):
+@pytest.mark.skipif(IS_WINDOWS, reason="SLiM not available on windows")
+class TestSimulate:
+    @pytest.mark.filterwarnings("ignore::msprime.IncompletePopulationMetadataWarning")
     def test_simulate_with_mask(self):
         engines = ["msprime", "slim"]
         species = stdpopsim.get_species("HomSap")
@@ -139,7 +141,7 @@ def test_simulate_with_mask(self):
             )
             # check that positions of mutations are within mask
             positions = np.array([ts.site(m.site).position for m in ts.mutations()])
-            self.assertTrue(np.all(positions >= L // 2))
+            assert np.all(positions >= L // 2)
 
             # test engine with inclusion mask
             contig.exclusion_mask = None
@@ -149,4 +151,4 @@ def test_simulate_with_mask(self):
             )
             # check that positions of mutations are within mask
             positions = np.array([ts.site(m.site).position for m in ts.mutations()])
-            self.assertTrue(np.all(positions < L // 2))
+            assert np.all(positions < L // 2)
diff --git a/tests/test_models.py b/tests/test_models.py
index da3b030f4..148a4b01b 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -1,7 +1,6 @@
 """
 Tests for simulation model infrastructure.
 """
-import unittest
 import sys
 import textwrap
 import copy
@@ -18,11 +17,10 @@
 from stdpopsim import utils
 
 
-class DemographicModelTestMixin(object):
+class DemographicModelTestMixin:
     """
     Mixin for testing specific models. Subclasses should extend
-    unittest.TestCase and this mixin, and define the self.model (as the
-    model instance).
+    this class and define the self.model (as the model instance).
     """
 
     # To be defined in subclasses.
@@ -32,10 +30,13 @@ def test_debug_runs(self):
         dbg = self.model.model.debug()
         assert dbg.num_epochs > 0
 
+    @pytest.mark.filterwarnings("ignore:.*Zigzag_1S14.*:UserWarning")
     def test_simulation_runs(self):
         # With a recombination_map of None, we simulate a coalescent without
         # recombination in msprime, with mutation rate equal to rate from model.
-        contig = stdpopsim.Contig(mutation_rate=self.model.mutation_rate)
+        contig = stdpopsim.Contig.basic_contig(
+            length=100, mutation_rate=self.model.mutation_rate
+        )
         # Generate vector with 2 samples for each pop with sampling enabled
         sample_count = []
         for p in self.model.populations:
@@ -46,7 +47,7 @@ def test_simulation_runs(self):
         samples = self.model.get_samples(*sample_count)
         engine = stdpopsim.get_default_engine()
         ts = engine.simulate(self.model, contig, samples)
-        self.assertEqual(ts.num_populations, self.model.num_populations)
+        assert ts.num_populations == self.model.num_populations
 
 
 class CatalogDemographicModelTestMixin(DemographicModelTestMixin):
@@ -55,7 +56,7 @@ class CatalogDemographicModelTestMixin(DemographicModelTestMixin):
     """
 
     def test_id_valid(self):
-        self.assertTrue(utils.is_valid_demographic_model_id(self.model.id))
+        assert utils.is_valid_demographic_model_id(self.model.id)
 
 
 class QcdCatalogDemographicModelTestMixin(CatalogDemographicModelTestMixin):
@@ -73,19 +74,19 @@ def test_qc_model_equal(self):
     def test_generation_time_match(self):
         g1 = self.model.generation_time
         g2 = self.model.qc_model.generation_time
-        self.assertEqual(g1, g2)
+        assert g1 == g2
 
     def test_mutation_rate_match(self):
         u1 = self.model.mutation_rate
         u2 = self.model.qc_model.mutation_rate
-        self.assertEqual(u1, u2)
+        assert u1 == u2
 
 
 # Add model specific test classes, derived from one of the above.
 qc_test_classes = []
 for species in stdpopsim.all_species():
     for model in species.demographic_models:
-        superclasses = [unittest.TestCase]
+        superclasses = []
         if model.qc_model is not None:
             superclasses.append(QcdCatalogDemographicModelTestMixin)
         else:
@@ -97,12 +98,12 @@ def test_mutation_rate_match(self):
 # that lead to these qc tests being skipped silently.
 assert len(qc_test_classes) > 0
 for cls in qc_test_classes:
-    assert issubclass(cls, unittest.TestCase)
+    assert issubclass(cls, DemographicModelTestMixin)
     # Insert the class into the current test module's namespace.
     setattr(sys.modules[__name__], cls.__name__, cls)
 
 
-class TestRegisterQCModel(unittest.TestCase):
+class TestRegisterQCModel:
     def make_model(self, name):
         return models.DemographicModel(
             id=name,
@@ -119,13 +120,13 @@ def test_register_qc(self):
     def test_already_registered(self):
         model = self.make_model("test")
         model.register_qc(model)
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             model.register_qc(model)
 
     def test_bad_qc_models(self):
         model = self.make_model("test")
         for not_a_model in [None, 15, "Zigzag_1S14"]:
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 model.register_qc(not_a_model)
 
 
@@ -189,7 +190,7 @@ def test_wrap_long_lines(self):
         assert textwrap.dedent(expected) in s
 
 
-class TestModelsEqual(unittest.TestCase):
+class TestModelsEqual:
     """
     Tests DemographicModel object equality comparison.
     """
@@ -202,77 +203,77 @@ def test_known_models(self):
             assert not model.model.is_equivalent(other_model)
 
 
-class TestConstantSizeModel(unittest.TestCase, DemographicModelTestMixin):
+class TestConstantSizeModel(DemographicModelTestMixin):
     model = models.PiecewiseConstantSize(100)
 
 
-class TestTwoEpochModel(unittest.TestCase, DemographicModelTestMixin):
+class TestTwoEpochModel(DemographicModelTestMixin):
     model = models.PiecewiseConstantSize(100, (10, 10))
 
 
-class TestPiecewiseConstantSize(unittest.TestCase):
+class TestPiecewiseConstantSize:
     """
     Specific tests for the piecewise constant model.
     """
 
     def test_single_epoch(self):
         model = models.PiecewiseConstantSize(100)
-        self.assertEqual(model.model.populations[0].initial_size, 100)
-        self.assertEqual(model.model.num_populations, 1)
-        self.assertEqual(model.model.num_events, 0)
+        assert model.model.populations[0].initial_size == 100
+        assert model.model.num_populations == 1
+        assert model.model.num_events == 0
 
     def test_two_epoch(self):
         model = models.PiecewiseConstantSize(50, (10, 100))
-        self.assertEqual(model.model.populations[0].initial_size, 50)
-        self.assertEqual(model.model.num_populations, 1)
-        self.assertEqual(model.model.num_events, 1)
+        assert model.model.populations[0].initial_size == 50
+        assert model.model.num_populations == 1
+        assert model.model.num_events == 1
         event = model.model.events[0]
-        self.assertEqual(event.time, 10)
-        self.assertEqual(event.initial_size, 100)
-        self.assertIsNone(event.growth_rate)
+        assert event.time == 10
+        assert event.initial_size == 100
+        assert event.growth_rate is None
 
     def test_three_epoch(self):
         model = models.PiecewiseConstantSize(0.1, (0.1, 10), (0.2, 100))
-        self.assertEqual(model.model.populations[0].initial_size, 0.1)
-        self.assertEqual(model.model.num_populations, 1)
-        self.assertEqual(model.model.num_events, 2)
+        assert model.model.populations[0].initial_size == 0.1
+        assert model.model.num_populations == 1
+        assert model.model.num_events == 2
         event = model.model.events[0]
-        self.assertEqual(event.time, 0.1)
-        self.assertEqual(event.initial_size, 10)
-        self.assertIsNone(event.growth_rate)
+        assert event.time == 0.1
+        assert event.initial_size == 10
+        assert event.growth_rate is None
         event = model.model.events[1]
-        self.assertEqual(event.time, 0.2)
-        self.assertEqual(event.initial_size, 100)
-        self.assertIsNone(event.growth_rate)
+        assert event.time == 0.2
+        assert event.initial_size == 100
+        assert event.growth_rate is None
 
 
-class TestIsolationWithMigration(unittest.TestCase):
+class TestIsolationWithMigration:
     """
     Tests for the generic IM model.
     """
 
     def test_pop_configs(self):
         model = models.IsolationWithMigration(100, 200, 300, 50, 0, 0).model
-        self.assertEqual(model.num_populations, 3)
-        self.assertEqual(model.populations[0].initial_size, 200)
-        self.assertEqual(model.populations[1].initial_size, 300)
-        self.assertEqual(model.populations[2].initial_size, 100)
+        assert model.num_populations == 3
+        assert model.populations[0].initial_size == 200
+        assert model.populations[1].initial_size == 300
+        assert model.populations[2].initial_size == 100
 
     def test_split(self):
         model = models.IsolationWithMigration(100, 200, 300, 50, 0, 0).model
-        self.assertEqual(len(model.events), 1)
-        self.assertEqual(model.events[0].time, 50)
+        assert len(model.events) == 1
+        assert model.events[0].time == 50
 
     def test_migration_rates(self):
         model = models.IsolationWithMigration(100, 200, 300, 50, 0.002, 0.003).model
-        self.assertEqual(np.shape(model.migration_matrix), (3, 3))
-        self.assertEqual(model.migration_matrix[0][1], 0.002)
-        self.assertEqual(model.migration_matrix[1][0], 0.003)
+        assert np.shape(model.migration_matrix) == (3, 3)
+        assert model.migration_matrix[0][1] == 0.002
+        assert model.migration_matrix[1][0] == 0.003
         # check these are the only two nonzero entries in the migration matrix
-        self.assertEqual(np.sum(np.array(model.migration_matrix) != 0), 2)
+        assert np.sum(np.array(model.migration_matrix) != 0) == 2
 
 
-class TestPopulationSampling(unittest.TestCase):
+class TestPopulationSampling:
     def make_model(self):
         # Create populations to test on
         _pop1 = stdpopsim.Population("pop0", "Test pop. 0")
@@ -295,24 +296,24 @@ def make_model(self):
 
     def test_num_sampling_populations(self):
         base_mod = self.make_model()
-        self.assertEqual(base_mod.num_sampling_populations, 2)
+        assert base_mod.num_sampling_populations == 2
 
     def test_get_samples(self):
         base_mod = self.make_model()
         test_samples = base_mod.get_samples(2, 1)
-        self.assertEqual(len(test_samples), 2)
-        self.assertEqual(sum([ss.num_samples for ss in test_samples]), 3)
+        assert len(test_samples) == 2
+        assert sum([ss.num_samples for ss in test_samples]) == 3
 
         # Check for error when prohibited sampling asked for
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             base_mod.get_samples(2, 2, 1)
         # Get the population corresponding to each sample
         sample_populations = [i.population for i in test_samples]
         # Check sample populations
-        self.assertEqual(sample_populations, [0, 1])
+        assert sample_populations == [0, 1]
         # Test sampling times
         sample_times = [i.time for i in test_samples]
-        self.assertEqual(sample_times, [0, 10])
+        assert sample_times == [0, 10]
 
     # Test that all sampling populations are specified before non-sampling populations
     # in the model.populations list
@@ -321,49 +322,50 @@ def test_population_order(self):
             allow_sample_status = [int(p.allow_samples) for p in model.populations]
             num_sampling = sum(allow_sample_status)
             # All sampling populations must be at the start of the list
-            self.assertEqual(sum(allow_sample_status[num_sampling:]), 0)
+            assert sum(allow_sample_status[num_sampling:]) == 0
 
 
-class TestZigZagWarning(unittest.TestCase):
+class TestZigZagWarning:
     def test_zigzag_produces_warning(self):
         species = stdpopsim.get_species("HomSap")
         model = species.get_demographic_model("Zigzag_1S14")
         contig = species.get_contig("chr22")
         samples = model.get_samples(10)
         for engine in stdpopsim.all_engines():
-            with self.assertWarnsRegex(UserWarning, "Zigzag_1S14"):
+            with pytest.warns(UserWarning, match="Zigzag_1S14 model.*sizes 5x lower"):
                 engine.simulate(model, contig, samples, dry_run=True)
 
 
-class TestMutationRates(unittest.TestCase):
+class TestMutationRates:
     def test_mutation_rate_warning(self):
         species = stdpopsim.get_species("HomSap")
         model = copy.deepcopy(species.get_demographic_model("OutOfAfrica_3G09"))
-        ###
-        # unneeded after mutation rates are included in catalog!!
-        model.mutation_rate = 2.35e-8
-        ###
         contig = species.get_contig("chr22")
         samples = model.get_samples(10, 10, 10)
         for engine in stdpopsim.all_engines():
-            with self.assertWarnsRegex(UserWarning, "mutation"):
+            with pytest.warns(
+                UserWarning, match="model has mutation rate.*but this simulation used"
+            ):
                 engine.simulate(model, contig, samples, dry_run=True)
 
+    @pytest.mark.filterwarnings(
+        "error:.*model has mutation rate.*but this simulation used.*"
+    )
     def test_mutation_rate_match(self):
         species = stdpopsim.get_species("HomSap")
         model = copy.deepcopy(species.get_demographic_model("OutOfAfrica_3G09"))
-        ###
-        # unneeded after mutation rates are included in catalog!!
-        model.mutation_rate = 2.35e-8
-        ###
         contig = species.get_contig("chr22")
-        self.assertNotEqual(model.mutation_rate, contig.mutation_rate)
+        assert model.mutation_rate != contig.mutation_rate
         contig = species.get_contig("chr22", mutation_rate=model.mutation_rate)
-        self.assertEqual(model.mutation_rate, contig.mutation_rate)
+        assert model.mutation_rate == contig.mutation_rate
         contig = species.get_contig(length=100)
-        self.assertNotEqual(model.mutation_rate, contig.mutation_rate)
+        assert model.mutation_rate != contig.mutation_rate
         contig = species.get_contig(length=100, mutation_rate=model.mutation_rate)
-        self.assertEqual(model.mutation_rate, contig.mutation_rate)
+        assert model.mutation_rate == contig.mutation_rate
+
+        samples = model.get_samples(10, 10, 10)
+        for engine in stdpopsim.all_engines():
+            engine.simulate(model, contig, samples, dry_run=True)
 
     def test_params_match_docs_tables(self):
         for species in stdpopsim.all_species():
@@ -374,7 +376,7 @@ def test_params_match_docs_tables(self):
                     )
                 )
                 if model.qc_model is not None:
-                    self.assertTrue(table_path.exists())
+                    assert table_path.exists()
                     with open(table_path) as csv_file:
                         reader = csv.reader(csv_file)
                         param_list = list(reader)
@@ -385,5 +387,5 @@ def test_params_match_docs_tables(self):
                                 generation_time = float(param_data[1])
                             if param_data[0].startswith("Mutation rate"):
                                 mutation_rate = float(param_data[1])
-                    self.assertEqual(model.mutation_rate, mutation_rate)
-                    self.assertEqual(model.generation_time, generation_time)
+                    assert model.mutation_rate == mutation_rate
+                    assert model.generation_time == generation_time
diff --git a/tests/test_slim_engine.py b/tests/test_slim_engine.py
index 84f3002c3..045e5513c 100644
--- a/tests/test_slim_engine.py
+++ b/tests/test_slim_engine.py
@@ -6,12 +6,12 @@
 import io
 import sys
 import itertools
-import unittest
 import tempfile
 import math
 from unittest import mock
 import numpy as np
 
+import pytest
 import tskit
 import pyslim
 import msprime
@@ -48,8 +48,8 @@ def slim_simulate_no_recap(seed=1234, **kwargs):
     return ts
 
 
-@unittest.skipIf(IS_WINDOWS, "SLiM not available on windows")
-class TestAPI(unittest.TestCase):
+@pytest.mark.skipif(IS_WINDOWS, reason="SLiM not available on windows")
+class TestAPI:
     def test_bad_params(self):
         engine = stdpopsim.get_engine("slim")
         species = stdpopsim.get_species("HomSap")
@@ -58,7 +58,7 @@ def test_bad_params(self):
         samples = model.get_samples(10)
 
         for scaling_factor in (0, -1, -1e-6):
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 engine.simulate(
                     demographic_model=model,
                     contig=contig,
@@ -68,7 +68,7 @@ def test_bad_params(self):
                 )
 
         for burn_in in (-1, -1e-6):
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 engine.simulate(
                     demographic_model=model,
                     contig=contig,
@@ -77,6 +77,10 @@ def test_bad_params(self):
                     dry_run=True,
                 )
 
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
+    @pytest.mark.filterwarnings(
+        "ignore:.*model has mutation rate.*but this simulation used.*"
+    )
     def test_script_generation(self):
         engine = stdpopsim.get_engine("slim")
         species = stdpopsim.get_species("HomSap")
@@ -91,7 +95,7 @@ def test_script_generation(self):
             samples=samples,
             slim_script=True,
         )
-        self.assertTrue("sim.registerLateEvent" in out)
+        assert "sim.registerLateEvent" in out
 
         model = species.get_demographic_model("AncientEurasia_9K19")
         samples = model.get_samples(10, 20, 30, 40, 50, 60, 70)
@@ -102,7 +106,7 @@ def test_script_generation(self):
             samples=samples,
             slim_script=True,
         )
-        self.assertTrue("sim.registerLateEvent" in out)
+        assert "sim.registerLateEvent" in out
 
         model = species.get_demographic_model("AmericanAdmixture_4B11")
         samples = model.get_samples(10, 10, 10)
@@ -113,36 +117,24 @@ def test_script_generation(self):
             samples=samples,
             slim_script=True,
         )
-        self.assertTrue("sim.registerLateEvent" in out)
-
-    def test_no_recombination_map(self):
-        engine = stdpopsim.get_engine("slim")
-        species = stdpopsim.get_species("HomSap")
-        contig = species.get_contig("chr1", genetic_map="HapMapII_GRCh37")
-        model = stdpopsim.PiecewiseConstantSize(species.population_size)
-        contig.recombination_map = None
-        with self.assertRaises(ValueError):
-            engine.simulate(
-                demographic_model=model,
-                contig=contig,
-                samples=model.get_samples(10),
-                dry_run=True,
-            )
+        assert "sim.registerLateEvent" in out
 
+    @pytest.mark.filterwarnings("ignore:Recombination map has length:UserWarning")
     def test_recombination_map(self):
         engine = stdpopsim.get_engine("slim")
         species = stdpopsim.get_species("HomSap")
         contig = species.get_contig("chr1", genetic_map="HapMapII_GRCh37")
         model = stdpopsim.PiecewiseConstantSize(species.population_size)
         samples = model.get_samples(10)
-        out, _ = capture_output(
-            engine.simulate,
+        engine.simulate(
             demographic_model=model,
             contig=contig,
             samples=samples,
             dry_run=True,
         )
 
+    @pytest.mark.filterwarnings("ignore::msprime.IncompletePopulationMetadataWarning")
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
     def test_simulate(self):
         engine = stdpopsim.get_engine("slim")
         species = stdpopsim.get_species("AraTha")
@@ -156,9 +148,14 @@ def test_simulate(self):
             slim_scaling_factor=10,
             slim_burn_in=0,
         )
-        self.assertEqual(ts.num_samples, 10)
-        self.assertTrue(all(tree.num_roots == 1 for tree in ts.trees()))
-
+        assert ts.num_samples == 10
+        assert all(tree.num_roots == 1 for tree in ts.trees())
+
+    @pytest.mark.filterwarnings("ignore::msprime.IncompletePopulationMetadataWarning")
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
+    @pytest.mark.filterwarnings(
+        "ignore:.*model has mutation rate.*but this simulation used.*"
+    )
     def test_recap_and_rescale(self):
         engine = stdpopsim.get_engine("slim")
         species = stdpopsim.get_species("HomSap")
@@ -202,18 +199,18 @@ def test_recap_and_rescale(self):
             tables1 = ts1.dump_tables()
             tables2 = ts2.dump_tables()
 
-            self.assertEqual(tables1.nodes, tables2.nodes)
-            self.assertEqual(tables1.edges, tables2.edges)
-            self.assertEqual(tables1.mutations, tables2.mutations)
+            assert tables1.nodes == tables2.nodes
+            assert tables1.edges == tables2.edges
+            assert tables1.mutations == tables2.mutations
 
     def test_assert_min_version(self):
         engine = stdpopsim.get_engine("slim")
         with mock.patch(
             "stdpopsim.slim_engine._SLiMEngine.get_version", return_value="3.4"
         ):
-            with self.assertRaises(RuntimeError):
+            with pytest.raises(RuntimeError):
                 engine._assert_min_version("3.5", engine.slim_path())
-            with self.assertRaises(RuntimeError):
+            with pytest.raises(RuntimeError):
                 engine._assert_min_version("4.0", None)
         with mock.patch(
             "stdpopsim.slim_engine._SLiMEngine.get_version", return_value="4.0"
@@ -222,35 +219,39 @@ def test_assert_min_version(self):
             engine._assert_min_version("3.6", None)
 
 
-@unittest.skipIf(IS_WINDOWS, "SLiM not available on windows")
-class TestCLI(unittest.TestCase):
+@pytest.mark.skipif(IS_WINDOWS, reason="SLiM not available on windows")
+class TestCLI:
     def docmd(self, _cmd):
         cmd = (
-            "-q -e slim --slim-scaling-factor 20 --slim-burn-in 0 "
-            f"{_cmd} -l 0.001 -c chr1 -s 1234 10"
+            f"-q -e slim --slim-burn-in 0 {_cmd} -l 0.001 -c chr1 -s 1234 10"
         ).split()
         return capture_output(stdpopsim.cli.stdpopsim_main, cmd)
 
     def test_script_generation(self):
         out, _ = self.docmd("--slim-script HomSap")
-        self.assertTrue("sim.registerLateEvent" in out)
+        assert "sim.registerLateEvent" in out
 
         # msprime.MassMigration demographic events, with proportion<1.0
         # low level migration
         out, _ = self.docmd("--slim-script HomSap -d AncientEurasia_9K19")
-        self.assertTrue("sim.registerLateEvent" in out)
+        assert "sim.registerLateEvent" in out
         # simultaneous mass migrations, with proportions summing to 1.0
         out, _ = self.docmd("--slim-script HomSap -d AmericanAdmixture_4B11")
-        self.assertTrue("sim.registerLateEvent" in out)
+        assert "sim.registerLateEvent" in out
 
+    @pytest.mark.filterwarnings("ignore::msprime.IncompletePopulationMetadataWarning")
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
+    @pytest.mark.filterwarnings("ignore:.*has only.*individuals alive")
     def test_simulate(self):
         saved_slim_env = os.environ.get("SLIM")
         slim_path = os.environ.get("SLIM", "slim")
         with tempfile.NamedTemporaryFile(mode="w") as f:
-            self.docmd(f"--slim-path {slim_path} HomSap -o {f.name}")
+            self.docmd(
+                f"--slim-scaling-factor 20 --slim-path {slim_path} HomSap -o {f.name}"
+            )
             ts = tskit.load(f.name)
-        self.assertEqual(ts.num_samples, 10)
-        self.assertTrue(all(tree.num_roots == 1 for tree in ts.trees()))
+        assert ts.num_samples == 10
+        assert all(tree.num_roots == 1 for tree in ts.trees())
 
         if saved_slim_env is None:
             del os.environ["SLIM"]
@@ -258,9 +259,9 @@ def test_simulate(self):
             os.environ["SLIM"] = saved_slim_env
 
         with tempfile.NamedTemporaryFile(mode="w") as f:
-            self.docmd(f"HomSap -o {f.name}")
+            self.docmd(f"--slim-scaling-factor 20 HomSap -o {f.name}")
             ts = tskit.load(f.name)
-        self.assertEqual(ts.num_samples, 10)
+        assert ts.num_samples == 10
 
         # verify sample counts for a multipopulation demographic model
         with tempfile.NamedTemporaryFile(mode="w") as f:
@@ -271,14 +272,12 @@ def test_simulate(self):
             ).split()
             capture_output(stdpopsim.cli.stdpopsim_main, cmd)
             ts = tskit.load(f.name)
-        self.assertEqual(ts.num_populations, 3)
+        assert ts.num_populations == 3
         observed_counts = [0, 0, 0]
         for sample in ts.samples():
             observed_counts[ts.get_population(sample)] += 1
-        self.assertEqual(observed_counts[0], 0)
-        self.assertEqual(observed_counts[1], 0)
-        self.assertEqual(observed_counts[2], 8)
-        self.assertTrue(all(tree.num_roots == 1 for tree in ts.trees()))
+        assert observed_counts == [0, 0, 8]
+        assert all(tree.num_roots == 1 for tree in ts.trees())
 
     @mock.patch("stdpopsim.slim_engine._SLiMEngine.get_version", return_value="64.64")
     def test_dry_run(self, _mocked_get_version):
@@ -294,16 +293,16 @@ def test_dry_run(self, _mocked_get_version):
                 self.docmd(f"HomSap --dry-run -o {f.name}")
         mocked_popen.assert_called_once()
         slim_path = os.environ.get("SLIM", "slim")
-        self.assertTrue(slim_path in mocked_popen.call_args[0][0])
+        assert slim_path in mocked_popen.call_args[0][0]
         with tempfile.NamedTemporaryFile(mode="w") as f:
             self.docmd(f"HomSap --dry-run -o {f.name}")
-            self.assertEqual(os.stat(f.name).st_size, 0)
+            assert os.stat(f.name).st_size == 0
 
     def test_bad_slim_environ_var(self):
         saved_slim_env = os.environ.get("SLIM")
 
         os.environ["SLIM"] = "nonexistent"
-        with self.assertRaises(FileNotFoundError):
+        with pytest.raises(FileNotFoundError):
             self.docmd("HomSap")
 
         if saved_slim_env is None:
@@ -314,7 +313,7 @@ def test_bad_slim_environ_var(self):
     def test_bad_slim_path(self):
         saved_slim_env = os.environ.get("SLIM")
 
-        with self.assertRaises(FileNotFoundError):
+        with pytest.raises(FileNotFoundError):
             self.docmd("--slim-path nonexistent HomSap")
 
         if saved_slim_env is None:
@@ -323,43 +322,64 @@ def test_bad_slim_path(self):
             os.environ["SLIM"] = saved_slim_env
 
 
-@unittest.skipIf(IS_WINDOWS, "SLiM not available on windows")
-class TestWarningsAndErrors(unittest.TestCase):
+@pytest.mark.skipif(IS_WINDOWS, reason="SLiM not available on windows")
+class TestWarningsAndErrors:
     """
     Checks that warning messages are printed when appropriate.
     """
 
-    # this is an expected failure, because no warning should be emitted
-    @unittest.expectedFailure
-    def test_odd_sample_warning_for_even_samples(self):
-        cmd = "-q -e slim --slim-script HomSap -d OutOfAfrica_2T12 4 6".split()
-        with self.assertWarns(stdpopsim.SLiMOddSampleWarning):
-            capture_output(stdpopsim.cli.stdpopsim_main, cmd)
-
-    @unittest.skip("FIXME")
-    # This test seems to be broken - the warning is being issued as we can see it
-    # in the debug stream, but it's not getting captured here.
-    def test_odd_sample_warning(self):
-        cmd = "-q -e slim --slim-script HomSap -d OutOfAfrica_2T12 -L 100 4 5".split()
-        with self.assertWarns(stdpopsim.SLiMOddSampleWarning):
-            capture_output(stdpopsim.cli.stdpopsim_main, cmd)
-
-        cmd = "-q -e slim --slim-script HomSap -d OutOfAfrica_2T12 -L 100 3 5".split()
-        with self.assertWarns(stdpopsim.SLiMOddSampleWarning):
-            capture_output(stdpopsim.cli.stdpopsim_main, cmd)
-
     def triplet(self):
         engine = stdpopsim.get_engine("slim")
         species = stdpopsim.get_species("HomSap")
         contig = species.get_contig("chr22", length_multiplier=0.001)
         return engine, species, contig
 
-    def test_bad_population_size_addSubPop(self):
+    @pytest.mark.filterwarnings("error::stdpopsim.SLiMOddSampleWarning")
+    @pytest.mark.filterwarnings(
+        "ignore:.*model has mutation rate.*but this simulation used.*"
+    )
+    def test_no_odd_sample_warning_for_even_samples(self):
+        engine, species, contig = self.triplet()
+        model = species.get_demographic_model("OutOfAfrica_2T12")
+        samples = model.get_samples(4, 6)
+        engine.simulate(
+            demographic_model=model,
+            contig=contig,
+            samples=samples,
+            dry_run=True,
+        )
+
+    def test_odd_sample_warning(self):
+        engine, species, contig = self.triplet()
+        model = stdpopsim.PiecewiseConstantSize(100)
+        samples = model.get_samples(5)
+        with pytest.warns(stdpopsim.SLiMOddSampleWarning):
+            engine.simulate(
+                demographic_model=model,
+                contig=contig,
+                samples=samples,
+                dry_run=True,
+            )
+
+        model = species.get_demographic_model("OutOfAfrica_2T12")
+        samples = model.get_samples(2, 5)
+        with pytest.warns(stdpopsim.SLiMOddSampleWarning):
+            engine.simulate(
+                demographic_model=model,
+                contig=contig,
+                samples=samples,
+                dry_run=True,
+            )
+
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
+    def test_bad_population_size_addSubPop_warning(self):
         engine, species, contig = self.triplet()
         model = stdpopsim.PiecewiseConstantSize(100)
         samples = model.get_samples(2)
 
-        with self.assertWarns(stdpopsim.UnspecifiedSLiMWarning):
+        with pytest.warns(
+            stdpopsim.UnspecifiedSLiMWarning, match="has only.*individuals alive"
+        ):
             engine.simulate(
                 demographic_model=model,
                 contig=contig,
@@ -368,12 +388,13 @@ def test_bad_population_size_addSubPop(self):
                 dry_run=True,
             )
 
-    def test_no_populations_in_generation_1(self):
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
+    def test_no_populations_in_generation1_error(self):
         engine, species, contig = self.triplet()
         model = stdpopsim.PiecewiseConstantSize(100)
         samples = model.get_samples(2)
 
-        with self.assertRaises(stdpopsim.SLiMException):
+        with pytest.raises(stdpopsim.SLiMException):
             engine.simulate(
                 demographic_model=model,
                 contig=contig,
@@ -382,14 +403,16 @@ def test_no_populations_in_generation_1(self):
                 dry_run=True,
             )
 
-    def test_bad_population_size_addSubpopSplit(self):
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
+    def test_bad_population_size_addSubpopSplit_warning(self):
         engine, species, contig = self.triplet()
         model = stdpopsim.IsolationWithMigration(
             NA=1000, N1=100, N2=1000, T=1000, M12=0, M21=0
         )
         samples = model.get_samples(2)
-
-        with self.assertWarns(stdpopsim.UnspecifiedSLiMWarning):
+        with pytest.warns(
+            stdpopsim.UnspecifiedSLiMWarning, match="has only.*individuals alive"
+        ):
             engine.simulate(
                 demographic_model=model,
                 contig=contig,
@@ -398,7 +421,15 @@ def test_bad_population_size_addSubpopSplit(self):
                 dry_run=True,
             )
 
-        with self.assertRaises(stdpopsim.SLiMException):
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
+    @pytest.mark.filterwarnings("ignore:.*has only.*individuals alive")
+    def test_bad_population_size_addSubpopSplit_error(self):
+        engine, species, contig = self.triplet()
+        model = stdpopsim.IsolationWithMigration(
+            NA=1000, N1=100, N2=1000, T=1000, M12=0, M21=0
+        )
+        samples = model.get_samples(2)
+        with pytest.raises(stdpopsim.SLiMException):
             engine.simulate(
                 demographic_model=model,
                 contig=contig,
@@ -407,12 +438,14 @@ def test_bad_population_size_addSubpopSplit(self):
                 dry_run=True,
             )
 
-    def test_bad_population_size_setSubpopulationSize(self):
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
+    def test_bad_population_size_setSubpopulationSize_warning(self):
         engine, species, contig = self.triplet()
         model = stdpopsim.PiecewiseConstantSize(100, (1000, 1000))
         samples = model.get_samples(2)
-
-        with self.assertWarns(stdpopsim.UnspecifiedSLiMWarning):
+        with pytest.warns(
+            stdpopsim.UnspecifiedSLiMWarning, match="has only.*individuals alive"
+        ):
             engine.simulate(
                 demographic_model=model,
                 contig=contig,
@@ -421,7 +454,13 @@ def test_bad_population_size_setSubpopulationSize(self):
                 dry_run=True,
             )
 
-        with self.assertRaises(stdpopsim.SLiMException):
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
+    @pytest.mark.filterwarnings("ignore:.*has only.*individuals alive")
+    def test_bad_population_size_setSubpopulationSize_error(self):
+        engine, species, contig = self.triplet()
+        model = stdpopsim.PiecewiseConstantSize(100, (1000, 1000))
+        samples = model.get_samples(2)
+        with pytest.raises(stdpopsim.SLiMException):
             engine.simulate(
                 demographic_model=model,
                 contig=contig,
@@ -430,12 +469,13 @@ def test_bad_population_size_setSubpopulationSize(self):
                 dry_run=True,
             )
 
-    def test_sample_size_too_big(self):
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
+    def test_sample_size_too_big_error(self):
         engine, species, contig = self.triplet()
         model = stdpopsim.PiecewiseConstantSize(1000)
         samples = model.get_samples(300)
 
-        with self.assertRaises(stdpopsim.SLiMException):
+        with pytest.raises(stdpopsim.SLiMException):
             engine.simulate(
                 demographic_model=model,
                 contig=contig,
@@ -471,12 +511,14 @@ def exp_decline(self, N0=100, N1=1000, T=1000):
             ],
         )
 
-    def test_bad_population_size_exp_decline(self):
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
+    def test_bad_population_size_exp_decline_warning(self):
         engine, species, contig = self.triplet()
         model = self.exp_decline()
         samples = model.get_samples(2)
-
-        with self.assertWarns(stdpopsim.UnspecifiedSLiMWarning):
+        with pytest.warns(
+            stdpopsim.UnspecifiedSLiMWarning, match="has only.*individuals alive"
+        ):
             engine.simulate(
                 demographic_model=model,
                 contig=contig,
@@ -485,7 +527,13 @@ def test_bad_population_size_exp_decline(self):
                 dry_run=True,
             )
 
-        with self.assertRaises(stdpopsim.SLiMException):
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
+    @pytest.mark.filterwarnings("ignore:.*has only.*individuals alive")
+    def test_bad_population_size_exp_decline_error(self):
+        engine, species, contig = self.triplet()
+        model = self.exp_decline()
+        samples = model.get_samples(2)
+        with pytest.raises(stdpopsim.SLiMException):
             engine.simulate(
                 demographic_model=model,
                 contig=contig,
@@ -494,12 +542,14 @@ def test_bad_population_size_exp_decline(self):
                 dry_run=False,
             )
 
-    def test_sample_size_too_big_exp_decline(self):
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
+    @pytest.mark.filterwarnings("ignore:.*has only.*individuals alive")
+    def test_sample_size_too_big_exp_decline_error(self):
         engine, species, contig = self.triplet()
         model = self.exp_decline()
         samples = model.get_samples(30)
 
-        with self.assertRaises(stdpopsim.SLiMException):
+        with pytest.raises(stdpopsim.SLiMException):
             engine.simulate(
                 demographic_model=model,
                 contig=contig,
@@ -508,42 +558,50 @@ def test_sample_size_too_big_exp_decline(self):
                 dry_run=True,
             )
 
-    # this is an expected failure, because no warning should be emitted
-    @unittest.expectedFailure
-    def test_warning_when_not_scaling(self):
-        with self.assertWarns(stdpopsim.SLiMScalingFactorWarning):
-            for cmd in [
-                "HomSap 100 -D",
-                "-e slim HomSap 100 -D",
-                "-e slim --slim-scaling-factor 1 HomSap 100 -D",
-                "-e slim --slim-scaling-factor 1.0 HomSap 100 -D",
-            ]:
-                capture_output(stdpopsim.cli.stdpopsim_main, cmd.split())
-
-    def test_warning_when_scaling(self):
-        for cmd in [
-            "-e slim --slim-scaling-factor 2 HomSap 100 -D -L 1",
-            "-e slim --slim-scaling-factor 1000 EscCol 100 -D -L 1",
-        ]:
-            with self.assertWarns(stdpopsim.SLiMScalingFactorWarning):
-                capture_output(stdpopsim.cli.stdpopsim_main, cmd.split())
-
-    def test_slim_engine_without_recombination_map(self):
+    @pytest.mark.filterwarnings("error::stdpopsim.SLiMScalingFactorWarning")
+    def test_no_warning_when_not_scaling(self):
         engine, species, contig = self.triplet()
-        contig.recombination_map = None
-        model = stdpopsim.PiecewiseConstantSize(1000)
+        model = stdpopsim.PiecewiseConstantSize(10000)
         samples = model.get_samples(100)
+        engine.simulate(
+            demographic_model=model,
+            contig=contig,
+            samples=samples,
+            dry_run=True,
+        )
+
+        engine.simulate(
+            demographic_model=model,
+            contig=contig,
+            samples=samples,
+            dry_run=True,
+            slim_scaling_factor=1,
+        )
 
-        with self.assertRaises(ValueError):
+        engine.simulate(
+            demographic_model=model,
+            contig=contig,
+            samples=samples,
+            dry_run=True,
+            slim_scaling_factor=1.0,
+        )
+
+    @pytest.mark.parametrize("scaling_factor", [2, 4.3])
+    def test_warning_when_scaling(self, scaling_factor):
+        engine, species, contig = self.triplet()
+        model = stdpopsim.PiecewiseConstantSize(10000)
+        samples = model.get_samples(100)
+        with pytest.warns(stdpopsim.SLiMScalingFactorWarning):
             engine.simulate(
                 demographic_model=model,
                 contig=contig,
                 samples=samples,
+                slim_scaling_factor=scaling_factor,
                 dry_run=True,
             )
 
 
-class TestSlimAvailable(unittest.TestCase):
+class TestSlimAvailable:
     """
     Checks whether SLiM is available or not on platforms that support it.
     """
@@ -553,11 +611,11 @@ def test_parser_has_options(self):
         with mock.patch("sys.exit", autospec=True):
             _, stderr = capture_output(parser.parse_args, ["--help"])
             # On windows we should have no "slim" options
-            self.assertEqual(IS_WINDOWS, "slim" not in stderr)
+            assert IS_WINDOWS == ("slim" not in stderr)
 
     def test_engine_available(self):
         all_engines = [engine.id for engine in stdpopsim.all_engines()]
-        self.assertEqual(IS_WINDOWS, "slim" not in all_engines)
+        assert IS_WINDOWS == ("slim" not in all_engines)
 
 
 def get_test_contig(spp="HomSap", chrom="chr22", length_multiplier=0.001):
@@ -600,24 +658,8 @@ def allele_frequency(self, ts):
         return af
 
 
-@unittest.skipIf(IS_WINDOWS, "SLiM not available on windows")
-class TestGenomicElementTypes(unittest.TestCase, PiecewiseConstantSizeMixin):
-    def test_default_fully_neutral(self):
-        # if no genomic element type is defined, then by default go fully neutral
-        contig = get_test_contig()
-        contig.genomic_element_types = []
-        contig.mutation_types = []
-        engine = stdpopsim.get_engine("slim")
-        out, _ = capture_output(
-            engine.simulate,
-            demographic_model=self.model,
-            contig=contig,
-            samples=self.samples,
-            slim_script=True,
-        )
-        self.assertEqual(out.count("initializeGenomicElementType"), 1)
-        self.assertEqual(out.count("initializeGenomicElement("), 1)
-
+@pytest.mark.skipif(IS_WINDOWS, reason="SLiM not available on windows")
+class TestGenomicElementTypes(PiecewiseConstantSizeMixin):
     def test_single_genomic_element_type_in_script(self):
         contig = get_test_contig()
         engine = stdpopsim.get_engine("slim")
@@ -628,8 +670,8 @@ def test_single_genomic_element_type_in_script(self):
             samples=self.samples,
             slim_script=True,
         )
-        self.assertEqual(out.count("initializeGenomicElementType"), 1)
-        self.assertEqual(out.count("initializeGenomicElement("), 1)
+        assert out.count("initializeGenomicElementType") == 1
+        assert out.count("initializeGenomicElement(") == 1
 
     def test_multiple_genomic_element_types_in_script(self):
         contig = get_test_contig()
@@ -657,12 +699,12 @@ def test_multiple_genomic_element_types_in_script(self):
             samples=self.samples,
             slim_script=True,
         )
-        self.assertEqual(out.count("initializeGenomicElementType"), 3)
-        self.assertEqual(out.count("initializeGenomicElement("), 3)
+        assert out.count("initializeGenomicElementType") == 3
+        assert out.count("initializeGenomicElement(") == 3
 
 
-@unittest.skipIf(IS_WINDOWS, "SLiM not available on windows")
-class TestMutationTypes(unittest.TestCase, PiecewiseConstantSizeMixin):
+@pytest.mark.skipif(IS_WINDOWS, reason="SLiM not available on windows")
+class TestMutationTypes(PiecewiseConstantSizeMixin):
     def test_single_mutation_type_in_script(self):
         contig = get_test_contig()
         engine = stdpopsim.get_engine("slim")
@@ -673,7 +715,7 @@ def test_single_mutation_type_in_script(self):
             samples=self.samples,
             slim_script=True,
         )
-        self.assertEqual(out.count("initializeMutationType"), 1)
+        assert out.count("initializeMutationType") == 1
 
         out, _ = capture_output(
             engine.simulate,
@@ -682,7 +724,7 @@ def test_single_mutation_type_in_script(self):
             samples=self.samples,
             slim_script=True,
         )
-        self.assertEqual(out.count("initializeMutationType"), 1)
+        assert out.count("initializeMutationType") == 1
 
     def test_multiple_mutation_types_in_script(self):
         contig = get_test_contig()
@@ -698,7 +740,7 @@ def test_multiple_mutation_types_in_script(self):
             samples=self.samples,
             slim_script=True,
         )
-        self.assertEqual(out.count("initializeMutationType"), 2)
+        assert out.count("initializeMutationType") == 2
 
         positive = stdpopsim.ext.MutationType(convert_to_substitution=False)
         contig.mutation_types = [stdpopsim.ext.MutationType() for i in range(10)] + [
@@ -713,8 +755,9 @@ def test_multiple_mutation_types_in_script(self):
             samples=self.samples,
             slim_script=True,
         )
-        self.assertEqual(out.count("initializeMutationType"), 11)
+        assert out.count("initializeMutationType") == 11
 
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
     def test_unweighted_mutations_are_not_simulated_by_slim(self):
         contig = get_test_contig()
         contig.mutation_types = [
@@ -730,7 +773,7 @@ def test_unweighted_mutations_are_not_simulated_by_slim(self):
             slim_scaling_factor=10,
             slim_burn_in=0.1,
         )
-        self.assertEqual(ts.num_sites, 0)
+        assert ts.num_sites == 0
 
         contig.mutation_types = [
             stdpopsim.ext.MutationType(),
@@ -746,8 +789,9 @@ def test_unweighted_mutations_are_not_simulated_by_slim(self):
             slim_scaling_factor=10,
             slim_burn_in=0.1,
         )
-        self.assertEqual(ts.num_sites, 0)
+        assert ts.num_sites == 0
 
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
     def test_weighted_mutations_are_simulated_by_slim(self):
         contig = get_test_contig()
         contig.mutation_types = [
@@ -761,7 +805,7 @@ def test_weighted_mutations_are_simulated_by_slim(self):
             slim_scaling_factor=10,
             slim_burn_in=0.1,
         )
-        self.assertTrue(ts.num_sites > 0)
+        assert ts.num_sites > 0
 
         contig.mutation_types = [
             stdpopsim.ext.MutationType(convert_to_substitution=False)
@@ -774,7 +818,7 @@ def test_weighted_mutations_are_simulated_by_slim(self):
             slim_scaling_factor=10,
             slim_burn_in=0.1,
         )
-        self.assertTrue(ts.num_sites > 0)
+        assert ts.num_sites > 0
 
         contig.mutation_types = [stdpopsim.ext.MutationType() for i in range(10)]
         contig.genomic_element_types[0].proportions = [1 / 10 for i in range(10)]
@@ -786,7 +830,7 @@ def test_weighted_mutations_are_simulated_by_slim(self):
             slim_scaling_factor=10,
             slim_burn_in=0.1,
         )
-        self.assertTrue(ts.num_sites > 0)
+        assert ts.num_sites > 0
 
     def test_dominance_coeff(self):
         for dominance_coeff in (0, 0.5, 1, 50):
@@ -794,12 +838,12 @@ def test_dominance_coeff(self):
 
     def test_bad_dominance_coeff(self):
         for dominance_coeff in (-1,):
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 stdpopsim.ext.MutationType(dominance_coeff=dominance_coeff)
 
     def test_bad_distribution_type(self):
         for distribution_type in (1, {}, None, "~", "!", "F"):
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 stdpopsim.ext.MutationType(distribution_type=distribution_type)
 
     def test_distribution_type_f(self):
@@ -810,7 +854,7 @@ def test_distribution_type_f(self):
 
     def test_bad_distribution_args_f(self):
         for distribution_args in ([0.1, 0.2], []):
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 stdpopsim.ext.MutationType(
                     distribution_type="f", distribution_args=distribution_args
                 )
@@ -823,14 +867,14 @@ def test_distribution_type_g(self):
 
     def test_bad_distribution_args_g(self):
         for distribution_args in ([], [0.1, 0], [0.1, -0.1], [0.1, 0.4, 0.5]):
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 stdpopsim.ext.MutationType(
                     distribution_type="g", distribution_args=distribution_args
                 )
 
 
-@unittest.skipIf(IS_WINDOWS, "SLiM not available on windows")
-class TestDrawMutation(unittest.TestCase, PiecewiseConstantSizeMixin):
+@pytest.mark.skipif(IS_WINDOWS, reason="SLiM not available on windows")
+class TestDrawMutation(PiecewiseConstantSizeMixin):
     def test_draw_mutation_save(self):
         extended_events = [
             stdpopsim.ext.DrawMutation(
@@ -879,7 +923,7 @@ def test_invalid_mutation_type_id(self):
                     coordinate=100,
                 ),
             ]
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 engine.simulate(
                     demographic_model=self.model,
                     contig=self.contig,
@@ -900,7 +944,7 @@ def test_no_mutation_types_defined(self):
         contig = get_test_contig()
         contig.mutation_types = []
         engine = stdpopsim.get_engine("slim")
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             engine.simulate(
                 demographic_model=self.model,
                 contig=contig,
@@ -911,12 +955,12 @@ def test_no_mutation_types_defined(self):
 
     def test_bad_time(self):
         for time in (-1,):
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 stdpopsim.ext.DrawMutation(
                     time=time, mutation_type_id=0, population_id=0, coordinate=0
                 )
         for time in (0, -1):
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 stdpopsim.ext.DrawMutation(
                     time=stdpopsim.ext.GenerationAfter(time),
                     mutation_type_id=0,
@@ -925,8 +969,8 @@ def test_bad_time(self):
                 )
 
 
-@unittest.skipIf(IS_WINDOWS, "SLiM not available on windows")
-class TestAlleleFrequencyConditioning(unittest.TestCase, PiecewiseConstantSizeMixin):
+@pytest.mark.skipif(IS_WINDOWS, reason="SLiM not available on windows")
+class TestAlleleFrequencyConditioning(PiecewiseConstantSizeMixin):
     def test_save_point_creation(self):
         extended_events = [
             stdpopsim.ext.DrawMutation(
@@ -964,6 +1008,7 @@ def test_save_point_creation(self):
             dry_run=True,
         )
 
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
     def test_drawn_mutation_not_lost(self):
         extended_events = [
             stdpopsim.ext.DrawMutation(
@@ -990,8 +1035,9 @@ def test_drawn_mutation_not_lost(self):
             slim_scaling_factor=10,
             slim_burn_in=0.1,
         )
-        self.assertEqual(ts.num_mutations, 1)
+        assert ts.num_mutations == 1
 
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
     def test_drawn_mutation_is_lost(self):
         extended_events = [
             stdpopsim.ext.DrawMutation(
@@ -1018,8 +1064,9 @@ def test_drawn_mutation_is_lost(self):
             slim_scaling_factor=10,
             slim_burn_in=0.1,
         )
-        self.assertEqual(ts.num_mutations, 0)
+        assert ts.num_mutations == 0
 
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
     def test_drawn_mutation_meets_AF_threshold(self):
         for af_threshold, seed in zip((0.01, 0.1, 0.2), (1, 2, 3)):
             extended_events = [
@@ -1049,8 +1096,8 @@ def test_drawn_mutation_meets_AF_threshold(self):
                 slim_burn_in=0.1,
                 seed=seed,
             )
-            self.assertEqual(ts.num_mutations, 1)
-            self.assertTrue(self.allele_frequency(ts) >= af_threshold)
+            assert ts.num_mutations == 1
+            assert self.allele_frequency(ts) >= af_threshold
 
     def test_bad_AF_conditioning_parameters(self):
         for op, af in [
@@ -1070,7 +1117,7 @@ def test_bad_AF_conditioning_parameters(self):
             (">=", 0),
             ("<=", 1),
         ]:
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 stdpopsim.ext.ConditionOnAlleleFrequency(
                     start_time=0,
                     end_time=0,
@@ -1082,7 +1129,7 @@ def test_bad_AF_conditioning_parameters(self):
 
     def test_bad_times(self):
         for start_time, end_time in [(-1, 0), (0, -1), (1, 100)]:
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 stdpopsim.ext.ConditionOnAlleleFrequency(
                     start_time=start_time,
                     end_time=end_time,
@@ -1105,7 +1152,7 @@ def test_bad_GenerationAfter_times(self):
             (1e-9, 0),
             (100 + 1e-9, 100),
         ]:
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 extended_events = [
                     stdpopsim.ext.DrawMutation(
                         time=self.T_mut,
@@ -1135,11 +1182,12 @@ def test_op_id(self):
         op_types = stdpopsim.ext.ConditionOnAlleleFrequency.op_types
         for op in op_types:
             id = stdpopsim.ext.ConditionOnAlleleFrequency.op_id(op)
-            self.assertTrue(0 <= id < len(op_types))
+            assert 0 <= id < len(op_types)
         for op in ("==", "=", "!=", {}, ""):
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 id = stdpopsim.ext.ConditionOnAlleleFrequency.op_id(op)
 
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
     def test_conditioning_without_save(self):
         extended_events = [
             stdpopsim.ext.DrawMutation(
@@ -1158,7 +1206,7 @@ def test_conditioning_without_save(self):
             ),
         ]
         engine = stdpopsim.get_engine("slim")
-        with self.assertRaises(stdpopsim.SLiMException):
+        with pytest.raises(stdpopsim.SLiMException):
             # TODO: get this to fail using dry_run=True
             engine.simulate(
                 demographic_model=self.model,
@@ -1182,7 +1230,7 @@ def test_no_drawn_mutation(self):
             ),
         ]
         engine = stdpopsim.get_engine("slim")
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             engine.simulate(
                 demographic_model=self.model,
                 contig=self.contig,
@@ -1192,8 +1240,55 @@ def test_no_drawn_mutation(self):
             )
 
 
-@unittest.skipIf(IS_WINDOWS, "SLiM not available on windows")
-class TestChangeMutationFitness(unittest.TestCase, PiecewiseConstantSizeMixin):
+@pytest.mark.skipif(IS_WINDOWS, reason="SLiM not available on windows")
+class TestFixedSelectionCoefficient(PiecewiseConstantSizeMixin):
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
+    def test_drawn_mutation_has_correct_selection_coeff(self):
+        contig = self.contig
+        contig.mutation_types.append(
+            stdpopsim.ext.MutationType(
+                distribution_type="f",
+                dominance_coeff=0.5,
+                distribution_args=[0.1],
+                convert_to_substitution=False,
+            )
+        )
+        mut_id = len(contig.mutation_types) - 1
+        extended_events = [
+            stdpopsim.ext.DrawMutation(
+                time=self.T_mut,
+                mutation_type_id=mut_id,
+                population_id=0,
+                coordinate=100,
+                save=True,
+            ),
+            stdpopsim.ext.ConditionOnAlleleFrequency(
+                start_time=0,
+                end_time=0,
+                mutation_type_id=mut_id,
+                population_id=0,
+                op=">",
+                allele_frequency=0,
+            ),
+        ]
+        scaling_factor = 10
+        ts = slim_simulate_no_recap(
+            demographic_model=self.model,
+            contig=self.contig,
+            samples=self.samples,
+            extended_events=extended_events,
+            slim_scaling_factor=scaling_factor,
+            slim_burn_in=0.1,
+        )
+        assert ts.num_mutations == 1
+        mut = next(ts.mutations())
+        mutation_list = mut.metadata["mutation_list"]
+        assert len(mutation_list) == 1
+        assert mutation_list[0]["selection_coeff"] == scaling_factor * 0.1
+
+
+@pytest.mark.skipif(IS_WINDOWS, reason="SLiM not available on windows")
+class TestChangeMutationFitness(PiecewiseConstantSizeMixin):
     # Testing stdpopsim.ext.ChangeMutationFitness is challenging, because
     # the side-effects are not deterministic. But if we condition on fixation
     # of a drawn mutation, such a simulation will be very slow without strong
@@ -1201,6 +1296,7 @@ class TestChangeMutationFitness(unittest.TestCase, PiecewiseConstantSizeMixin):
     # simulation until we get one that meets the allele frequency condition).
     # So if this test takes more than a few seconds to run, that's a good
     # indication that selection is not acting.
+    @pytest.mark.filterwarnings("ignore::stdpopsim.SLiMScalingFactorWarning")
     def test_positive_mutation_meets_AF_threshold(self):
         for af_threshold, seed in zip((0.5, 1), (1, 2)):
             extended_events = [
@@ -1248,8 +1344,8 @@ def test_positive_mutation_meets_AF_threshold(self):
                 slim_burn_in=0.1,
                 seed=seed,
             )
-            self.assertEqual(ts.num_mutations, 1)
-            self.assertTrue(self.allele_frequency(ts) >= af_threshold)
+            assert ts.num_mutations == 1
+            assert self.allele_frequency(ts) >= af_threshold
 
     def test_no_drawn_mutation(self):
         extended_events = [
@@ -1263,7 +1359,7 @@ def test_no_drawn_mutation(self):
             ),
         ]
         engine = stdpopsim.get_engine("slim")
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             engine.simulate(
                 demographic_model=self.model,
                 contig=self.contig,
@@ -1274,7 +1370,7 @@ def test_no_drawn_mutation(self):
 
     def test_bad_times(self):
         for start_time, end_time in [(-1, 0), (0, -1), (1, 100)]:
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 stdpopsim.ext.ChangeMutationFitness(
                     start_time=start_time,
                     end_time=end_time,
@@ -1297,7 +1393,7 @@ def test_bad_GenerationAfter_times(self):
             (1e-9, 0),
             (100 + 1e-9, 100),
         ]:
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 extended_events = [
                     stdpopsim.ext.DrawMutation(
                         time=self.T_mut,
@@ -1324,8 +1420,8 @@ def test_bad_GenerationAfter_times(self):
                 )
 
 
-@unittest.skipIf(IS_WINDOWS, "SLiM not available on windows")
-class TestExtendedEvents(unittest.TestCase, PiecewiseConstantSizeMixin):
+@pytest.mark.skipif(IS_WINDOWS, reason="SLiM not available on windows")
+class TestExtendedEvents(PiecewiseConstantSizeMixin):
     def test_bad_extended_events(self):
         engine = stdpopsim.get_engine("slim")
         for bad_ee in [
@@ -1334,7 +1430,7 @@ def test_bad_extended_events(self):
             {},
             "",
         ]:
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 engine.simulate(
                     demographic_model=self.model,
                     contig=self.contig,
@@ -1344,7 +1440,7 @@ def test_bad_extended_events(self):
                 )
 
 
-class TestMiscFunctions(unittest.TestCase):
+class TestMiscFunctions:
     def get_test_contig(self):
         species = stdpopsim.get_species("HomSap")
         contig = species.get_contig("chr22", length_multiplier=0.001)
@@ -1352,15 +1448,13 @@ def get_test_contig(self):
 
     def test_slim_fractions(self):
         contig = self.get_test_contig()
-
-        self.assertTrue(stdpopsim.slim_engine.get_slim_fractions(contig).size == 0)
         contig.fully_neutral()
-        self.assertTrue(
-            np.isclose(stdpopsim.slim_engine.get_slim_fractions(contig), np.array([0]))
+        assert np.isclose(
+            stdpopsim.slim_engine.get_slim_fractions(contig), np.array([0])
         )
         contig.fully_neutral(slim_mutations=True)
-        self.assertTrue(
-            np.isclose(stdpopsim.slim_engine.get_slim_fractions(contig), np.array([1]))
+        assert np.isclose(
+            stdpopsim.slim_engine.get_slim_fractions(contig), np.array([1])
         )
 
         proportions = ([0, 1.0], [0.5, 0.5], [0, 0], [0.2, 0])
@@ -1375,30 +1469,24 @@ def test_slim_fractions(self):
                 ],
                 proportions=props,
             )
-            self.assertTrue(
-                (slim_frac == stdpopsim.slim_engine.get_slim_fractions(contig)).all()
-            )
+            assert (slim_frac == stdpopsim.slim_engine.get_slim_fractions(contig)).all()
 
     def test_msp_mutation_rate_map(self):
         contig = self.get_test_contig()
-        contig.fully_neutral()
         rmap, _ = stdpopsim.slim_engine.get_msp_and_slim_mutation_rate_maps(contig)
-        self.assertTrue(
-            np.allclose(
-                rmap.position,
-                np.array([0, int(contig.recombination_map.sequence_length)]),
-            )
+        assert np.allclose(
+            rmap.position,
+            np.array([0, int(contig.recombination_map.sequence_length)]),
         )
-        self.assertTrue(np.allclose(rmap.rate, np.array([contig.mutation_rate])))
+        assert np.allclose(rmap.rate, np.array([contig.mutation_rate]))
 
     def test_slim_mutation_rate_map(self):
         contig = self.get_test_contig()
-        contig.fully_neutral()
         _, (breaks, rates) = stdpopsim.slim_engine.get_msp_and_slim_mutation_rate_maps(
             contig
         )
-        self.assertTrue(breaks == [int(contig.recombination_map.sequence_length) - 1])
-        self.assertTrue(rates == [0.0])
+        assert breaks == [int(contig.recombination_map.sequence_length) - 1]
+        assert rates == [0.0]
 
     def test_complex_mutation_rate_maps(self):
         contig = self.get_test_contig()
@@ -1422,17 +1510,15 @@ def test_complex_mutation_rate_maps(self):
                 contig.mutation_rate * (1 - prop1),
                 contig.mutation_rate,
             ]
-            self.assertTrue(np.allclose(obs_msp_breaks, exp_msp_breaks))
-            self.assertTrue(
-                (np.isclose(np.array(obs_msp_rates), np.array(exp_msp_rates))).all()
-            )
+            assert np.allclose(obs_msp_breaks, exp_msp_breaks)
+            assert (np.isclose(np.array(obs_msp_rates), np.array(exp_msp_rates))).all()
 
             exp_slim_breaks = [9, 99, int(contig.recombination_map.sequence_length) - 1]
             exp_slim_rates = [0, contig.mutation_rate * prop1, 0]
-            self.assertEqual(obs_slim_breaks, exp_slim_breaks)
-            self.assertTrue(
-                (np.isclose(np.array(obs_slim_rates), np.array(exp_slim_rates))).all()
-            )
+            assert obs_slim_breaks == exp_slim_breaks
+            assert (
+                np.isclose(np.array(obs_slim_rates), np.array(exp_slim_rates))
+            ).all()
 
             contig.clear_genomic_mutation_types()
             contig.add_genomic_element_type(
@@ -1458,10 +1544,8 @@ def test_complex_mutation_rate_maps(self):
                 contig.mutation_rate * (1 - prop2),
                 contig.mutation_rate,
             ]
-            self.assertTrue(np.allclose(obs_msp_breaks, exp_msp_breaks))
-            self.assertTrue(
-                (np.isclose(np.array(obs_msp_rates), np.array(exp_msp_rates))).all()
-            )
+            assert np.allclose(obs_msp_breaks, exp_msp_breaks)
+            assert (np.isclose(np.array(obs_msp_rates), np.array(exp_msp_rates))).all()
 
             exp_slim_breaks = [
                 49,
@@ -1473,7 +1557,7 @@ def test_complex_mutation_rate_maps(self):
                 contig.mutation_rate * prop2,
                 0,
             ]
-            self.assertEqual(obs_slim_breaks, exp_slim_breaks)
-            self.assertTrue(
-                (np.isclose(np.array(obs_slim_rates), np.array(exp_slim_rates))).all()
-            )
+            assert obs_slim_breaks == exp_slim_breaks
+            assert (
+                np.isclose(np.array(obs_slim_rates), np.array(exp_slim_rates))
+            ).all()
diff --git a/tests/test_species.py b/tests/test_species.py
index b3c0432ce..1400f73a5 100644
--- a/tests/test_species.py
+++ b/tests/test_species.py
@@ -1,17 +1,17 @@
 """
 Tests for the generic species interface.
 """
-import unittest
 import math
 import numpy as np
 
 import msprime
+import pytest
 
 import stdpopsim
 from stdpopsim import utils
 
 
-class TestSpecies(unittest.TestCase):
+class TestSpecies:
     """
     Tests for basic methods for the species.
     """
@@ -19,32 +19,32 @@ class TestSpecies(unittest.TestCase):
     def test_str(self):
         for species in stdpopsim.all_species():
             s = str(species)
-            self.assertIsInstance(s, str)
-            self.assertGreater(len(s), 0)
+            assert isinstance(s, str)
+            assert len(s) > 0
 
     def test_ensembl_id(self):
         # Test the Ensembl species ID for some known species.
         species = stdpopsim.get_species("HomSap")
-        self.assertEqual(species.ensembl_id, "homo_sapiens")
+        assert species.ensembl_id == "homo_sapiens"
         species = stdpopsim.get_species("DroMel")
-        self.assertEqual(species.ensembl_id, "drosophila_melanogaster")
+        assert species.ensembl_id == "drosophila_melanogaster"
 
     def test_get_known_species(self):
         good = ["HomSap", "EscCol"]
         for species_id in good:
             species = stdpopsim.get_species(species_id)
-            self.assertIsInstance(species, stdpopsim.Species)
-            self.assertEqual(species.id, species_id)
+            assert isinstance(species, stdpopsim.Species)
+            assert species.id == species_id
 
     def test_get_unknown_species(self):
         bad = ["XXXX", ""]
         for species_name in bad:
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 stdpopsim.get_species(species_name)
 
     def test_add_duplicate_species(self):
         species = stdpopsim.get_species("HomSap")
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             stdpopsim.register_species(species)
 
     def test_get_known_genetic_map(self):
@@ -52,39 +52,40 @@ def test_get_known_genetic_map(self):
         species = stdpopsim.get_species("HomSap")
         for name in good:
             gmap = species.get_genetic_map(name)
-            self.assertIsInstance(gmap, stdpopsim.GeneticMap)
-            self.assertEqual(gmap.id, name)
+            assert isinstance(gmap, stdpopsim.GeneticMap)
+            assert gmap.id == name
 
     def test_get_unknown_genetic_map(self):
         bad = ["GDXXX", "", None]
         species = stdpopsim.get_species("HomSap")
         for name in bad:
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 species.get_genetic_map(name)
 
     def test_add_duplicate_genetic_map(self):
         species = stdpopsim.get_species("HomSap")
         genetic_map = species.get_genetic_map("HapMapII_GRCh37")
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             species.add_genetic_map(genetic_map)
 
     def test_add_duplicate_model(self):
         species = stdpopsim.get_species("HomSap")
         model = species.get_demographic_model("OutOfAfrica_3G09")
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             species.add_demographic_model(model)
 
     def test_get_unknown_annotation(self):
         bad = ["GDXXX", "", None]
         species = stdpopsim.get_species("HomSap")
         for name in bad:
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 species.get_annotations(name)
 
+    @pytest.mark.xfail  # HomSap annotation not currently available
     def test_add_duplicate_annotation(self):
         species = stdpopsim.get_species("HomSap")
         an = species.get_annotations("Ensembl_GRCh38_gff3")
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             species.add_annotations(an)
 
 
@@ -193,7 +194,7 @@ def test_citation_properties(self):
             citation.assert_valid()
 
 
-class TestAllGenomes(unittest.TestCase):
+class TestAllGenomes:
     """
     Tests for basic properties aon all genomes.
     """
@@ -201,11 +202,11 @@ class TestAllGenomes(unittest.TestCase):
     def test_str(self):
         for species in stdpopsim.all_species():
             s = str(species.genome)
-            self.assertIsInstance(s, str)
-            self.assertGreater(len(s), 0)
+            assert isinstance(s, str)
+            assert len(s) > 0
 
 
-class TestGetContig(unittest.TestCase):
+class TestGetContig:
     """
     Tests for the get contig method.
     """
@@ -216,47 +217,48 @@ def test_length_multiplier(self):
         contig1 = self.species.get_contig("chr22")
         for x in [0.125, 1.0, 2.0]:
             contig2 = self.species.get_contig("chr22", length_multiplier=x)
-            self.assertEqual(
-                round(contig1.recombination_map.position[-1] * x),
-                contig2.recombination_map.position[-1],
+            assert (
+                round(contig1.recombination_map.position[-1] * x)
+                == contig2.recombination_map.position[-1]
             )
 
     def test_length_multiplier_on_empirical_map(self):
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             self.species.get_contig(
                 "chr1", genetic_map="HapMapII_GRCh37", length_multiplier=2
             )
 
+    @pytest.mark.filterwarnings("ignore:Recombination map has length:UserWarning")
     def test_genetic_map(self):
         # TODO we should use a different map here so we're not hitting the cache.
         contig = self.species.get_contig("chr22", genetic_map="HapMapII_GRCh37")
-        self.assertIsInstance(contig.recombination_map, msprime.RateMap)
+        assert isinstance(contig.recombination_map, msprime.RateMap)
 
     def test_contig_options(self):
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             # cannot use genetic map with generic contig
             self.species.get_contig(genetic_map="ABC")
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             # cannot use length multiplier with generic contig
             self.species.get_contig(length_multiplier=0.1)
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             # must specify length with generic contig or give chromosome name
             self.species.get_contig()
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             # cannot specify length with named chromosome
             self.species.get_contig("chr1", length=1e6)
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             # cannot specify mask for generic contig
             self.species.get_contig(length=1e4, inclusion_mask=[(0, 100)])
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             # connot specify mask for generic contig
             self.species.get_contig(length=1e4, exclusion_mask=[(0, 100)])
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             # cannot use length multiplier with mask
             self.species.get_contig(
                 "chr22", inclusion_mask=[(0, 100)], length_multiplier=0.1
             )
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             # cannot use length multiplier with mask
             self.species.get_contig(
                 "chr22", exclusion_mask=[(0, 100)], length_multiplier=0.1
@@ -265,7 +267,7 @@ def test_contig_options(self):
     def test_generic_contig(self):
         L = 1e6
         contig = self.species.get_contig(length=L)
-        self.assertTrue(contig.recombination_map.sequence_length == L)
+        assert contig.recombination_map.sequence_length == L
 
         chrom_ids = np.arange(1, 23).astype("str")
         Ls = [c.length for c in self.species.genome.chromosomes if c.id in chrom_ids]
@@ -280,7 +282,5 @@ def test_generic_contig(self):
             if c.id in chrom_ids
         ]
 
-        self.assertTrue(contig.mutation_rate == np.average(us, weights=Ls))
-        self.assertTrue(
-            contig.recombination_map.mean_rate == np.average(rs, weights=Ls)
-        )
+        assert contig.mutation_rate == np.average(us, weights=Ls)
+        assert contig.recombination_map.mean_rate == np.average(rs, weights=Ls)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index d2ed2580e..3ed0292f9 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,29 +1,30 @@
 """
 Tests for the utils module.
 """
-import unittest
 import os
 import pathlib
 import tarfile
 import tempfile
+
 import numpy as np
+import pytest
 
 from stdpopsim import utils
 from stdpopsim import Chromosome, Genome
 
 
-class TestValidDemographicModelId(unittest.TestCase):
+class TestValidDemographicModelId:
     """
     Tests for the is_valid_demographic_model_id function.
     """
 
     def test_empty_string(self):
-        self.assertFalse(utils.is_valid_demographic_model_id(""))
+        assert not (utils.is_valid_demographic_model_id(""))
 
     def test_contains_spaces(self):
         bad_ids = ["CamelCase 4X19", " CamelCase4X19", "CamelCase4X19"]
         for bad_id in bad_ids:
-            self.assertFalse(utils.is_valid_demographic_model_id(bad_id))
+            assert not (utils.is_valid_demographic_model_id(bad_id))
 
     def test_too_many_sections(self):
         bad_ids = [
@@ -33,22 +34,22 @@ def test_too_many_sections(self):
             "CamelCase_4X19_1234_5678",
         ]
         for bad_id in bad_ids:
-            self.assertFalse(utils.is_valid_demographic_model_id(bad_id))
+            assert not (utils.is_valid_demographic_model_id(bad_id))
 
     def test_bad_name(self):
         bad_ids = ["camelCase_4X19", "1CamelCase_4X19", "Camel-Case_4X19"]
         for bad_id in bad_ids:
-            self.assertFalse(utils.is_valid_demographic_model_id(bad_id))
+            assert not (utils.is_valid_demographic_model_id(bad_id))
 
     def test_bad_populations(self):
         bad_ids = ["CamelCase_0X19", "CamelCase_0.1X19", "CamelCase_OneX19"]
         for bad_id in bad_ids:
-            self.assertFalse(utils.is_valid_demographic_model_id(bad_id))
+            assert not (utils.is_valid_demographic_model_id(bad_id))
 
     def test_bad_author(self):
         bad_ids = ["CamelCase_1019", "CamelCase_1-19", "CamelCase_1;19"]
         for bad_id in bad_ids:
-            self.assertFalse(utils.is_valid_demographic_model_id(bad_id))
+            assert not (utils.is_valid_demographic_model_id(bad_id))
 
     def test_bad_year(self):
         bad_ids = [
@@ -58,7 +59,7 @@ def test_bad_year(self):
             "CamelCase_1Xfive",
         ]
         for bad_id in bad_ids:
-            self.assertFalse(utils.is_valid_demographic_model_id(bad_id))
+            assert not (utils.is_valid_demographic_model_id(bad_id))
 
     def test_good_ids(self):
         good_ids = [
@@ -70,26 +71,26 @@ def test_good_ids(self):
             "C01234_1Y00",
         ]
         for good_id in good_ids:
-            self.assertTrue(utils.is_valid_demographic_model_id(good_id))
+            assert utils.is_valid_demographic_model_id(good_id)
 
 
-class TestValidGeneticMapId(unittest.TestCase):
+class TestValidGeneticMapId:
     """
     Tests for the is_valid_demographic_model_id function.
     """
 
     def test_empty_string(self):
-        self.assertFalse(utils.is_valid_genetic_map_id(""))
+        assert not (utils.is_valid_genetic_map_id(""))
 
     def test_contains_spaces(self):
         bad_ids = ["CamelCase ABCD", " CamelCase_ABCD", "CamelCase_ABCD "]
         for bad_id in bad_ids:
-            self.assertFalse(utils.is_valid_genetic_map_id(bad_id))
+            assert not (utils.is_valid_genetic_map_id(bad_id))
 
     def test_bad_name(self):
         bad_ids = ["camelCase_ABCD", "1CamelCase_ABCD", "Camel-Case_ABCD"]
         for bad_id in bad_ids:
-            self.assertFalse(utils.is_valid_genetic_map_id(bad_id))
+            assert not (utils.is_valid_genetic_map_id(bad_id))
 
     def test_bad_assembly(self):
         bad_ids = [
@@ -99,50 +100,50 @@ def test_bad_assembly(self):
             "CamelCase_AB.CD",
         ]
         for bad_id in bad_ids:
-            self.assertFalse(utils.is_valid_genetic_map_id(bad_id))
+            assert not (utils.is_valid_genetic_map_id(bad_id))
 
     def test_good_ids(self):
         good_ids = ["CamelCase_ABCD", "CamelCase_a", "CamelCase_1000", "C_ABCD", "C_X"]
         for good_id in good_ids:
-            self.assertTrue(utils.is_valid_genetic_map_id(good_id))
+            assert utils.is_valid_genetic_map_id(good_id)
 
 
-class TestValidSpeciesId(unittest.TestCase):
+class TestValidSpeciesId:
     """
     Tests for the is_valid_demographic_model_id function.
     """
 
     def test_empty_string(self):
-        self.assertFalse(utils.is_valid_species_id(""))
+        assert not (utils.is_valid_species_id(""))
 
     def test_contains_spaces(self):
         bad_ids = ["Cam Cas", " CamCas", "CamCas "]
         for bad_id in bad_ids:
-            self.assertFalse(utils.is_valid_species_id(bad_id))
+            assert not (utils.is_valid_species_id(bad_id))
 
     def test_bad_ids(self):
         bad_ids = ["camCas", "camcas", "CAMCAS", "C1mCas", "Camcas", "CamelCase"]
         for bad_id in bad_ids:
-            self.assertFalse(utils.is_valid_species_id(bad_id))
+            assert not (utils.is_valid_species_id(bad_id))
 
     def test_good_ids(self):
         bad_ids = ["CamCas", "HomSap"]
         for bad_id in bad_ids:
-            self.assertTrue(utils.is_valid_species_id(bad_id))
+            assert utils.is_valid_species_id(bad_id)
 
 
-class TestValidSpeciesName(unittest.TestCase):
+class TestValidSpeciesName:
     """
     Tests for the is_valid_demographic_model_id function.
     """
 
     def test_empty_string(self):
-        self.assertFalse(utils.is_valid_species_name(""))
+        assert not (utils.is_valid_species_name(""))
 
     def test_extra_spaces(self):
         bad_names = ["Pan  pan", " Pan pan", "Pan pan "]
         for bad_name in bad_names:
-            self.assertFalse(utils.is_valid_species_name(bad_name))
+            assert not (utils.is_valid_species_name(bad_name))
 
     def test_bad_names(self):
         bad_names = [
@@ -155,42 +156,42 @@ def test_bad_names(self):
             "Pan, pan",
         ]
         for bad_name in bad_names:
-            self.assertFalse(utils.is_valid_species_name(bad_name))
+            assert not (utils.is_valid_species_name(bad_name))
 
     def test_good_names(self):
         good_names = ["Pan pan", "Homo sapiens"]
         for good_name in good_names:
-            self.assertTrue(utils.is_valid_species_name(good_name))
+            assert utils.is_valid_species_name(good_name)
 
-    @unittest.skip("Implement more flexible species name")
+    @pytest.mark.skip("Implement more flexible species name")
     def test_three_or_more(self):
         bad_names = ["Pan pan pan"]
         for bad_name in bad_names:
-            self.assertTrue(utils.is_valid_species_name(bad_name))
+            assert utils.is_valid_species_name(bad_name)
 
 
-class TestValidSpeciesCommonName(unittest.TestCase):
+class TestValidSpeciesCommonName:
     """
     Tests for the is_valid_demographic_model_id function.
     """
 
     def test_empty_string(self):
-        self.assertFalse(utils.is_valid_species_common_name(""))
+        assert not (utils.is_valid_species_common_name(""))
 
     def test_bad_common_names(self):
         # TODO add more examples when we define restrictions (#330)
         bad_common_names = ["0", "human"]
         for bad_common_name in bad_common_names:
-            self.assertFalse(utils.is_valid_species_common_name(bad_common_name))
+            assert not (utils.is_valid_species_common_name(bad_common_name))
 
     def test_good_common_names(self):
         # TODO add more examples when we define restrictions (#330)
         good_names = ["Human", "Stuff and things"]
         for good_name in good_names:
-            self.assertTrue(utils.is_valid_species_common_name(good_name))
+            assert utils.is_valid_species_common_name(good_name)
 
 
-class TestDownload(unittest.TestCase):
+class TestDownload:
     def test_download_local_file(self):
         with tempfile.TemporaryDirectory() as tmpdir:
             tmpdir = pathlib.Path(tmpdir)
@@ -200,10 +201,10 @@ def test_download_local_file(self):
                 print("foofoofoo", file=f)
             url = input_filename.resolve().as_uri()
             utils.download(url, output_filename)
-            self.assertTrue(output_filename.exists())
+            assert output_filename.exists()
             with open(output_filename) as f:
                 contents = f.read()
-            self.assertEqual(contents.strip(), "foofoofoo")
+            assert contents.strip() == "foofoofoo"
 
     def test_download_nonexistent(self):
         with tempfile.TemporaryDirectory() as tmpdir:
@@ -214,12 +215,12 @@ def test_download_nonexistent(self):
                 input_filename.resolve().as_uri(),  # local file
                 "http://example.com/nonexistant",  # remote file
             ):
-                with self.assertRaises(OSError):
+                with pytest.raises(OSError):
                     utils.download(url, output_filename)
-                self.assertFalse(output_filename.exists())
+                assert not (output_filename.exists())
 
 
-class TestSha256(unittest.TestCase):
+class TestSha256:
     # These tests are not intended to comprehensively test sha256
     # calculations, as we assume the Python standard library tests
     # have that covered. Instead, these tests are to ensure that
@@ -231,10 +232,11 @@ def test_sha256_small(self):
             with open(filename, "wb") as f:
                 f.write(b"foo-bar-baz")
             sha256sum = utils.sha256(filename)
-            self.assertEqual(
-                sha256sum,
+            assert (
+                sha256sum
+                ==
                 # Calculated with `sha256sum` from GNU coreutils.
-                "269dce1a5bb90188b2d9cf542a7c30e410c7d8251e34a97bfea56062df51ae23",
+                "269dce1a5bb90188b2d9cf542a7c30e410c7d8251e34a97bfea56062df51ae23"
             )
 
     def test_sha256_big(self):
@@ -245,14 +247,15 @@ def test_sha256_big(self):
                 for i in range(1024 * 1024):
                     f.write(str(i).encode())
             sha256sum = utils.sha256(filename)
-            self.assertEqual(
-                sha256sum,
+            assert (
+                sha256sum
+                ==
                 # Calculated with `sha256sum` from GNU coreutils.
-                "995e0fde646f7dc98423af9a862be96014574bfa76be1186b484f796c4e58533",
+                "995e0fde646f7dc98423af9a862be96014574bfa76be1186b484f796c4e58533"
             )
 
 
-class TestCd(unittest.TestCase):
+class TestCd:
     def test_cd_context_manager(self):
         # On Mac, the path we enter with "cd" may differ from
         # the path we get with cwd() due to symlinks. So we
@@ -260,10 +263,10 @@ def test_cd_context_manager(self):
         old_cwd = pathlib.Path.cwd().resolve()
         with tempfile.TemporaryDirectory() as tmpdir:
             tmpdir = pathlib.Path(tmpdir).resolve()
-            self.assertNotEqual(old_cwd, tmpdir)
+            assert old_cwd != tmpdir
             with utils.cd(tmpdir):
-                self.assertEqual(pathlib.Path.cwd().resolve(), tmpdir)
-            self.assertEqual(pathlib.Path.cwd().resolve(), old_cwd)
+                assert pathlib.Path.cwd().resolve() == tmpdir
+            assert pathlib.Path.cwd().resolve() == old_cwd
 
 
 def rm_f(filename):
@@ -273,7 +276,7 @@ def rm_f(filename):
         pass
 
 
-class TestUntar(unittest.TestCase):
+class TestUntar:
     def test_untar(self):
         with tempfile.TemporaryDirectory() as tmpdir:
             tmpdir = pathlib.Path(tmpdir)
@@ -297,10 +300,10 @@ def test_untar(self):
             utils.untar(tar, dest)
             for name, sha in zip(test_files, sha_list):
                 filename = dest / name
-                self.assertTrue(filename.exists())
+                assert filename.exists()
                 # Check that extracted files have the same checksums as
                 # the files we put in the tar.
-                self.assertEqual(utils.sha256(filename), sha)
+                assert utils.sha256(filename) == sha
 
     # Security related tests for utils.untar().
     # For an extended discussion of tar-related security issues see:
@@ -319,7 +322,7 @@ def test_tar_path_traversal_attack(self):
                 with utils.cd(tmpdir):
                     with tarfile.open(tar, mode="w:gz") as tf:
                         tf.add("link")
-                with self.assertRaises(ValueError):
+                with pytest.raises(ValueError):
                     utils.untar(tar, dest)
                 rm_f(filename)
                 rm_f(tar)
@@ -337,7 +340,7 @@ def filt(info):
                             return info
 
                         tf.add("test-thing", filter=filt)
-                with self.assertRaises(ValueError):
+                with pytest.raises(ValueError):
                     utils.untar(tar, dest)
                 rm_f(filename)
                 rm_f(tar)
@@ -370,13 +373,13 @@ def filt(info):
                             return info
 
                         tf.add("irregular", filter=filt)
-                with self.assertRaises(ValueError):
+                with pytest.raises(ValueError):
                     utils.untar(tar, dest)
                 rm_f(filename)
                 rm_f(tar)
 
 
-class TestSynonyms(unittest.TestCase):
+class TestSynonyms:
     def test_add_digit_autosomes(self):
         chroms = [
             Chromosome(
@@ -389,7 +392,7 @@ def test_add_digit_autosomes(self):
         ]
         genome = Genome(chroms)
         utils.append_common_synonyms(genome)
-        self.assertTrue("chr1" in genome.chromosomes[0].synonyms)
+        assert "chr1" in genome.chromosomes[0].synonyms
 
     def test_add_drosophila_like_synonyms(self):
         chroms = [
@@ -403,7 +406,7 @@ def test_add_drosophila_like_synonyms(self):
         ]
         genome = Genome(chroms)
         utils.append_common_synonyms(genome)
-        self.assertTrue("chr1a" in genome.chromosomes[0].synonyms)
+        assert "chr1a" in genome.chromosomes[0].synonyms
 
     def test_add_sex_chrom_synonyms(self):
         chroms = [
@@ -419,7 +422,7 @@ def test_add_sex_chrom_synonyms(self):
         genome = Genome(chroms)
         utils.append_common_synonyms(genome)
         for chrom in genome.chromosomes:
-            self.assertTrue("chr" + chrom.id in chrom.synonyms)
+            assert "chr" + chrom.id in chrom.synonyms
 
     def test_add_only_unique_synonyms(self):
         chroms = [
@@ -433,18 +436,18 @@ def test_add_only_unique_synonyms(self):
         ]
         genome = Genome(chroms)
         utils.append_common_synonyms(genome)
-        self.assertTrue("chr1" in genome.chromosomes[0].synonyms)
-        self.assertEqual(len(genome.chromosomes[0].synonyms), 1)
+        assert "chr1" in genome.chromosomes[0].synonyms
+        assert len(genome.chromosomes[0].synonyms) == 1
 
 
-class TestIntervalUtilities(unittest.TestCase):
+class TestIntervalUtilities:
     def test_intervals_array_shape(self):
         # shape (n, x) where x >= 2
         bad_arrays = (np.array([10, 20]), np.array([[[10, 20]]]))
         for bad_array in bad_arrays:
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 utils.check_intervals_array_shape(intervals=bad_array)
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 utils.build_intervals_array(intervals=bad_array)
         good_arrays = (
             np.array([[10, 20], [30, 40]]),
@@ -453,7 +456,7 @@ def test_intervals_array_shape(self):
         for good_array in good_arrays:
             utils.check_intervals_array_shape(intervals=good_array)
             built_array = utils.build_intervals_array(intervals=good_array)
-            self.assertTrue((built_array == good_array).all())
+            assert (built_array == good_array).all()
 
     def test_invalid_intervals(self):
         # right <= left, overlapping, uncastable float->int
@@ -463,14 +466,14 @@ def test_invalid_intervals(self):
             np.array([[10, 20], [13, 41]]),
         )
         for invalid_array in invalid_arrays:
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 utils.build_intervals_array(intervals=invalid_array)
-            with self.assertRaises(ValueError):
+            with pytest.raises(ValueError):
                 utils.check_intervals_validity(intervals=invalid_array)
         # interval outside [start, end)
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             utils.build_intervals_array(intervals=[[10, 50]], start=20)
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             utils.check_intervals_validity(intervals=np.array([[10, 50]]), end=30)
 
     def test_intervals_casting(self):
@@ -481,9 +484,9 @@ def test_intervals_casting(self):
         )
         for intervals in castable_intervals:
             casted = utils.build_intervals_array(intervals)
-            self.assertTrue(isinstance(casted, np.ndarray) and casted.dtype == np.int64)
-            self.assertTrue(casted.shape[0] == len(intervals))
-            self.assertTrue(casted.shape[1] >= 2)
+            assert isinstance(casted, np.ndarray) and casted.dtype == np.int64
+            assert casted.shape[0] == len(intervals)
+            assert casted.shape[1] >= 2
 
     def test_interval_sorting(self):
         unsorted_intervals = (
@@ -492,5 +495,5 @@ def test_interval_sorting(self):
         )
         for intervals in unsorted_intervals:
             casted = utils.build_intervals_array(intervals)
-            self.assertFalse(np.all(np.diff(intervals[:, 0]) >= 0))
-            self.assertTrue(np.all(np.diff(casted[:, 0]) >= 0))
+            assert not (np.all(np.diff(intervals[:, 0]) >= 0))
+            assert np.all(np.diff(casted[:, 0]) >= 0)