diff --git a/src/Persephone.jl b/src/Persephone.jl
index 571eb1effd25c5db4aefd378e65dc7d2fd96dc6f..cdf484f3934e77924a28060ef3e6e0b5bae415a3 100644
--- a/src/Persephone.jl
+++ b/src/Persephone.jl
@@ -20,11 +20,17 @@ using
     GeoArrays, #XXX this is a big dependency - can we get rid of it?
     Logging,
     LoggingExtras,
-    #MacroTools, #may be useful: http://fluxml.ai/MacroTools.jl/stable/utilities/
     Random,
     StableRNGs,
     TOML
 
+## Packages to check out:
+# MacroTools, http://fluxml.ai/MacroTools.jl/stable/utilities/
+# Debugger, https://github.com/JuliaDebug/Debugger.jl
+# Makie, https://docs.makie.org/stable/
+# PackageCompiler, https://julialang.github.io/PackageCompiler.jl/stable/
+# SpatialEcology, https://github.com/EcoJulia/SpatialEcology.jl
+
 ## define exported functions and variables
 export
     #types
diff --git a/src/core/input.jl b/src/core/input.jl
index d493d2109534251c14d435f5905c30f03c8daf19..8060d2ae46d00eea4f391c57ecea8984e215fc02 100644
--- a/src/core/input.jl
+++ b/src/core/input.jl
@@ -64,14 +64,19 @@ function getsettings(configfile::String, seed::Union{Int64,Nothing}=nothing)
             end
         end
     end
-    # pre-process certain parameters
-    if !isnothing(seed)
-        settings["core.seed"] = seed
-    end
-    if settings["core.seed"] == 0
-        settings["core.seed"] = abs(rand(RandomDevice(), Int32))
-    end
-    defaultoutdir = defaults["core.outdir"]
+    !isnothing(seed) && (settings["core.seed"] = seed)
+    settings["internal.scanparams"] = scanparams
+    preprocessparameters(settings, defaults["core.outdir"])
+end
+
+"""
+    preprocessparameters(settings)
+
+Take the raw input parameters and process them (convert types, perform checks, etc.).
+This is a helper function for `getsettings()`.
+"""
+function preprocessparameters(settings::Dict{String,Any}, defaultoutdir::String)
+    (settings["core.seed"] == 0) && (settings["core.seed"] = abs(rand(RandomDevice(), Int32)))
     if settings["core.outdir"] == defaultoutdir
         outdir = defaultoutdir*"_"*string(Dates.today())*"_s"*string(settings["core.seed"])
         settings["core.outdir"] = outdir
@@ -79,8 +84,15 @@ function getsettings(configfile::String, seed::Union{Int64,Nothing}=nothing)
     if settings["core.startdate"] > settings["core.enddate"]
         Base.error("Enddate is earlier than startdate.") #TODO replace with exception
     end
-    !isempty(scanparams) && addprocs(settings["core.processors"])
-    settings["internal.scanparams"] = scanparams
+    settings["core.loglevel"] == "debug" ? settings["core.loglevel"] = Logging.Debug :
+        settings["core.loglevel"] == "warn" ? settings["core.loglevel"] = Logging.Warn :
+        settings["core.loglevel"] = Logging.Info
+    if !isempty(settings["internal.scanparams"])
+        # https://docs.julialang.org/en/v1/manual/distributed-computing/#code-availability
+        addprocs(settings["core.processors"])
+        #addprocs(exeflags="--project") ?
+        @everywhere include("../Persephone.jl") #FIXME
+    end
     settings
 end
 
@@ -90,7 +102,7 @@ end
 An internal utility function to convert the two-dimensional dict returned
 by `TOML.parsefile()` into a one-dimensional dict, so that instead of
 writing `settings["domain"]["param"]` one can use `settings["domain.param"]`.
-Can be reversed with `expandTOML()`.
+Can be reversed with `prepareTOML()`.
 """
 function flattenTOML(tomldict)
     flatdict = Dict{String, Any}()
@@ -127,7 +139,7 @@ function parsecommandline()
             arg_type = String
             required = false
         "--loglevel", "-l"
-            help = "verbosity: \"debug\", \"info\", or \"quiet\""
+            help = "verbosity: \"debug\", \"info\", or \"warn\""
             arg_type = String
             required = false
     end
diff --git a/src/core/output.jl b/src/core/output.jl
index 6711ff5ef871e6f5213b41980abc3cfd8cd4838c..470d4b0f7ed21cbf10abecea66b8ccb30801cfdb 100644
--- a/src/core/output.jl
+++ b/src/core/output.jl
@@ -22,36 +22,43 @@ function createdatadir(outdir::String, overwrite::Union{Bool,String})
             answer = readline()
             (answer == "yes") && (overwrite = true)
         end
-        if !overwrite
-            #TODO replace with exception
-            Base.error("Output directory exists, will not overwrite. Aborting.") 
-        else
+        !overwrite ? Base.error("Output directory exists, will not overwrite. Aborting.") :
             @warn "Overwriting existing output directory $(outdir)."
-        end
+        #TODO replace with exception
     end
     mkpath(outdir)
 end
 
 """
-    modellogger(logsetting, outdir)
+    modellogger(loglevel, outdir)
 
 Create a logger object that writes output both to screen and to a logfile.
 This object is stored as `model.logger` and can then be used with `with_logger()`.
 Note: requires `createdatadir()` to be run first.
 """
-function modellogger(logsetting::String, outdir::String)
+function modellogger(loglevel::LogLevel, outdir::String)
+    !isdir(outdir) && #TODO replace with exception
+        Base.error("$(outdir) does not exist. Call `createdatadir()` before `modellogger()`.")
     #XXX If this is a parallel run, should we turn off logging to screen?
-    loglevel = Logging.Info
-    if logsetting == "debug"
-        loglevel = Logging.Debug
-    elseif logsetting == "warn"
-        loglevel = Logging.Warn
-    end
     logfile = open(joinpath(outdir, LOGFILE), "w+")
     TeeLogger(ConsoleLogger(logfile, loglevel),
               ConsoleLogger(stdout, loglevel))
 end
 
+"""
+    withtestlogger(model)
+
+Replace the model logger with the currently active logger. This is intended to be
+used in the testsuite to circumvent a Julia issue, where @test_logs doesn't work
+with local loggers (https://github.com/JuliaLang/julia/issues/48456).
+"""
+function withtestlogger(model::AgentBasedModel)
+    # copied together from https://github.com/JuliaLang/julia/blob/master/base/logging.jl
+    logstate = current_task().logstate
+    logstate == nothing ? model.logger = global_logger() : model.logger = logstate.logger
+    model
+end
+
 """
     saveinputfiles(model)
 
@@ -59,6 +66,8 @@ Copy all input files into the output directory, including the actual parameter
 settings used. This allows replicating a run in future.
 """
 function saveinputfiles(model::AgentBasedModel)
+    #XXX If this is a parallel run, we should save the global config to the top-level
+    # output directory
     @debug "Setting up output directory $(@param(core.outdir))."
     currentcommit = read(`git rev-parse HEAD`, String)[1:8]
     open(joinpath(@param(core.outdir), basename(@param(core.configfile))), "w") do f
@@ -72,7 +81,7 @@ function saveinputfiles(model::AgentBasedModel)
             println(f, "# WARNING: Your repository contains uncommitted changes. This may")
             println(f, "#          compromise the reproducibility of this simulation run.\n")
         end
-        TOML.print(f, expandTOML(model.settings))
+        TOML.print(f, prepareTOML(model.settings))
     end
     # Copy the map files to the output folder
     lcmap = @param(core.landcovermap)
@@ -85,17 +94,23 @@ function saveinputfiles(model::AgentBasedModel)
 end
 
 """
-    expandTOML(dict)
+    prepareTOML(dict)
 
 An internal utility function to re-convert the one-dimensional dict created
-by `flattenTOML()` into the two-dimensional dict needed by `TOML.print()`.
+by `flattenTOML()` into the two-dimensional dict needed by `TOML.print()`,
+and convert any data types into TOML-compatible types where necessary.
 """
-function expandTOML(settingsdict)
+function prepareTOML(settings)
+    # convert data types
+    settings["core.loglevel"] == Logging.Debug ? settings["core.loglevel"] = "debug" :
+        settings["core.loglevel"] == Logging.Warn ? settings["core.loglevel"] = "warn" :
+        settings["core.loglevel"] = "info"
+    # convert dict structure
     fulldict = Dict{String, Dict{String, Any}}()
-    for parameter in keys(settingsdict)
+    for parameter in keys(settings)
         domain, param = split(parameter, ".")
         !(domain in keys(fulldict)) && (fulldict[domain] = Dict{String,Any}())
-        fulldict[domain][param] = settingsdict[parameter]
+        fulldict[domain][param] = settings[parameter]
     end
     fulldict
 end
diff --git a/src/nature/nature.jl b/src/nature/nature.jl
index 8f4eba6c4e243b058cabaa38466fbf1e6f14ae0f..3162a3cbad7dc87256d17980b1b6cadb35c143e3 100644
--- a/src/nature/nature.jl
+++ b/src/nature/nature.jl
@@ -187,6 +187,7 @@ macro trait(traitname)
     # (i.e. outside of a @phase block). Although this is specified in the documentation,
     # it is unexpected and liable to be overlooked. Can we add a third clause to
     # compensate for that?
+    #TODO replace with `animal.traitname` syntax using `getproperty()`/`setproperty!()`
     if traitname in fieldnames(Animal)
         :($(esc(:animal)).$(traitname))
     else
diff --git a/test/io_tests.jl b/test/io_tests.jl
index 2ee9b648d472fe381a762841686040c0df243b16..be9683ef8992e43ddf584236120be6f9f7d24f3c 100644
--- a/test/io_tests.jl
+++ b/test/io_tests.jl
@@ -4,6 +4,7 @@
 ###
 
 @testset "Model configuration" begin
+    # Test the configuration file
     settings = Ps.getsettings(TESTPARAMETERS)
     properties = Dict{Symbol,Any}(:settings=>settings)
     space = GridSpace((10,10), periodic=false)
@@ -11,10 +12,20 @@
 
     @test @param(core.configfile) == basename(TESTPARAMETERS)
     @test @param(core.startdate) == Date(2022, 2, 1)
+    @test @param(core.loglevel) == Logging.Warn
     @test @param(nature.targetspecies) == ["Wolpertinger", "Wyvern"]
     @param(core.enddate) = Date(2022,1,3)
     @test @param(core.enddate) == Date(2022,1,3)
-    #TODO test commandline parameters
+    # Test the parsing of commandline parameters
+    #XXX ARGS not recognised immediately - redefining a constant is problematic anyway
+    # Base.ARGS = ["--configfile", "test.toml", "-s", "314",
+    #              "--outdir", "random_results", "-l", "info"]
+    # settings = Ps.getsettings(TESTPARAMETERS)
+    # @test @param(core.configfile) == "test.toml"
+    # @test @param(core.seed) == 314
+    # @test @param(core.outdir) == "random_results"
+    # @test @param(core.loglevel) == Logging.Info
+    # Base.ARGS = []
 end
 
 @testset "Output functions" begin
@@ -24,18 +35,20 @@ end
     model = AgentBasedModel(Animal, space, properties=properties, warn=false)
     # test that the output directory is created with all files
     outdir = @param(core.outdir)
+    Ps.createdatadir(outdir, @param(core.overwrite))
+    @test isdir(outdir)
+    logger = Ps.modellogger(@param(core.loglevel), outdir)
     @test_logs((:debug, "Setting up output directory results_testsuite."),
                min_level=Logging.Debug, match_mode=:any,
-               Ps.setupdatadir(model))
-    @test isdir(outdir)
+               Ps.saveinputfiles(model))
     @test isfile(joinpath(outdir, @param(core.landcovermap)))
     @test isfile(joinpath(outdir, @param(core.farmfieldsmap)))
     @test isfile(joinpath(outdir, @param(core.configfile)))
     @test isfile(joinpath(outdir, Ps.LOGFILE))
     # check whether the overwrite warning/protection works
     logstring = "Overwriting existing output directory $(outdir)."
-    @test_logs (:warn, logstring) match_mode=:any Ps.setupdatadir(model)
-    #TODO test overwrite protection (requires parameter mutability)
+    #TODO test overwrite protection
+    @test_logs (:warn, logstring) match_mode=:any Ps.createdatadir(outdir, @param(core.overwrite))
     rm(outdir, force=true, recursive=true)
     #TODO test that creating a DataOutput works, and outputs data with the required frequency
 end
diff --git a/test/landscape_tests.jl b/test/landscape_tests.jl
index ddf5eb028dfff08a94379972f402cac6bf649686..7e338e9469a4641cf40d2eaa8590181f74ca83f3 100644
--- a/test/landscape_tests.jl
+++ b/test/landscape_tests.jl
@@ -15,7 +15,7 @@ Create a 6x6 landscape with three land cover types for testing:
     F F G G G G
     F F G G G G
 """
-function smalltestlandscape(agenttype::Type=Animal)
+function smalltestlandscape()
     landscape = Matrix{Pixel}(undef, 6, 6)
     for x in 1:6
         for y in 1:6
@@ -28,9 +28,10 @@ function smalltestlandscape(agenttype::Type=Animal)
     properties = Dict{Symbol,Any}(:date=>TESTSETTINGS["core.startdate"],
                                   :landscape=>landscape,
                                   :events=>Vector{FarmEvent}(),
+                                  :logger=>global_logger(),
                                   :dataoutputs=>Vector{DataOutput}(),
                                   :settings=>TESTSETTINGS)
-    return AgentBasedModel(agenttype, space, properties=properties,
+    return AgentBasedModel(Union{Farmer,Animal,FarmPlot}, space, properties=properties,
                            rng=StableRNG(TESTSETTINGS["core.seed"]), warn=false)
 end
 
@@ -62,11 +63,15 @@ end
     @test model.landscape[1,1].events == [Ps.tillage, Ps.sowing]
     @test model.landscape[2,1].events == [Ps.tillage]
     @test model.landscape[2,2].events == [Ps.sowing]
-    @test_logs (:info, "Simulating day 2022-02-01.") match_mode=:any stepsimulation!(model)
+    @test_logs((:info, "Simulating day 2022-02-01."),
+               match_mode=:any,
+               stepsimulation!(Ps.withtestlogger(model)))
     @test model.landscape[1,1].events == [Ps.sowing]
     @test model.landscape[2,1].events == []
     @test model.landscape[2,2].events == [Ps.sowing]
-    @test_logs (:info, "Simulating day 2022-02-02.") match_mode=:any stepsimulation!(model)
+    @test_logs((:info, "Simulating day 2022-02-02."),
+               match_mode=:any,
+               stepsimulation!(Ps.withtestlogger(model)))
     @test model.landscape[1,1].events == []
     @test model.landscape[2,1].events == []
     @test model.landscape[2,2].events == []
diff --git a/test/nature_tests.jl b/test/nature_tests.jl
index c0e4158f3863390e6a8baebd6a77634dcfbffb96..a78b8b17bd3c11a346296a0786fa0a30ba867ba6 100644
--- a/test/nature_tests.jl
+++ b/test/nature_tests.jl
@@ -5,7 +5,7 @@
 
 @testset "Habitat macros" begin
     # set up the testing landscape
-    model = smalltestlandscape(Union{Animal,FarmPlot})
+    model = smalltestlandscape()
     model.landscape[6,6] = Pixel(Ps.agriculture, 1, [])
     species::Dict{String,Any} = Dict("name"=>"test_animal")
     add_agent!((6,6), FarmPlot, model, [(6,6)], Ps.wheat, 1.2, 3.4)
@@ -68,7 +68,7 @@ end
 
 @testset "Species macros" begin
     # create a model landscape and a test species
-    model = smalltestlandscape(Union{Animal,Farmer,FarmPlot})
+    model = smalltestlandscape()
     
     @species Mermaid begin
         ageofmaturity = 2
@@ -80,7 +80,7 @@ end
             @respond Persephone.harvest @setphase(drought)
             @debug "Animal: $animal"
             if @trait(sex) == Persephone.female && @countanimals() < 3 &&
-                @trait(age) >= @trait(ageofmaturity) && @here(landcover) == Persephone.water
+                @trait(age) >= @trait(ageofmaturity) && @landcover() == Persephone.water
                 @reproduce()
             end
         end
@@ -107,19 +107,19 @@ end
     @test_logs((:debug, "Mermaid 1 is swimming happily in its pond."),
                (:debug, "Mermaid 2 is swimming happily in its pond."),
                min_level=Logging.Debug, match_mode=:any,
-               stepsimulation!(model))
+               stepsimulation!(Ps.withtestlogger(model)))
     @test model[1].age == 1
     @test model[2].traits["phase"] == "drought"
     createevent!(model, [pond], Ps.sowing)
     @test_logs((:debug, "Mermaid 1 is experiencing drought with 1 neighbour(s)."),
                (:debug, "Mermaid 2 is experiencing drought with 1 neighbour(s)."),
                min_level=Logging.Debug, match_mode=:any,
-               stepsimulation!(model))
+               stepsimulation!(Ps.withtestlogger(model)))
     @test_logs((:debug, "Mermaid 1 is swimming happily in its pond."),
                (:debug, "Mermaid 1 has reproduced."),
                (:debug, "Mermaid 2 is swimming happily in its pond."),
                min_level=Logging.Debug, match_mode=:any,
-               stepsimulation!(model))
+               stepsimulation!(Ps.withtestlogger(model)))
     @test Ps.countanimals(pond, model) == 3
     createevent!(model, [pond], Ps.pesticide)
     @test_logs((:debug, "Mermaid 1 is swimming happily in its pond."),
@@ -129,7 +129,7 @@ end
                (:debug, "Mermaid 3 is swimming happily in its pond."),
                (:debug, "Mermaid 3 has died from poisoning."),
                min_level=Logging.Debug, match_mode=:any,
-               stepsimulation!(model))
+               stepsimulation!(Ps.withtestlogger(model)))
     @test Ps.countanimals(pond, model) == 0
 
     # test @rand (this is done more easily outside of @species)
diff --git a/test/runtests.jl b/test/runtests.jl
index 45b93bf6a4d1303e9dcc6094a3952b03f9bddb69..363ffea34886c587318d69b916dee8405188e799 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -9,6 +9,7 @@ Pkg.activate("..")
 using Agents
 using Dates
 using Logging
+using LoggingExtras
 using Persephone
 using Random
 using StableRNGs
diff --git a/test/simulation_tests.jl b/test/simulation_tests.jl
index d9766bb2bbfda39734d6f94972bd0b3fa44355db..6c19e32756b480f39f338a938ec098a5e94336ca 100644
--- a/test/simulation_tests.jl
+++ b/test/simulation_tests.jl
@@ -9,6 +9,7 @@
     @test model.date == Date(2022,2,1)
     @test typeof(model.landscape) == Matrix{Pixel}
     @test typeof(model.dataoutputs) == Vector{DataOutput}
+    @test typeof(model.logger) == TeeLogger{Tuple{ConsoleLogger, ConsoleLogger}}
     @test length(model.dataoutputs) == 2
     @test model.events == Vector{FarmEvent}()
     @test nagents(model) == 2092+10+28
@@ -26,6 +27,7 @@ end
     scan = Ps.paramscan(settings, scanparams)
     outdirs = (s["core.outdir"] for s in scan)
     @test length(outdirs) == 12
+    #FIXME On worker 2: KeyError: key Persephone [039acd1d-2a07-4b33-b082-83a1ff0fd136] not found
     @test length(initialise(config)) == 12 #XXX This takes a long time
     for dir in testdirs
         @test dir in outdirs
@@ -51,7 +53,7 @@ end
     @test_logs((:info, "Simulating day 2022-02-01."),
                (:info, "Simulated 59 days."),
                min_level=Logging.Debug, match_mode=:any,
-               simulate!(model))
+               simulate!(Ps.withtestlogger(model)))
     @test model.date == Date(2022,4,1)
     @test rand() == rand1
 end
diff --git a/test/test_parameters.toml b/test/test_parameters.toml
index 8692eba4e7375b3c047b730eb6f7af84ca42a23a..7c8891dbac73c8bc10726165771bdfb07fdc46c4 100644
--- a/test/test_parameters.toml
+++ b/test/test_parameters.toml
@@ -10,6 +10,7 @@ farmfieldsmap = "fields_jena.tif" # location of the field geometry map
 outdir = "results_testsuite" # location and name of the output folder
 overwrite = true # overwrite the output directory? (true/false/"ask")
 loglevel = "warn" # verbosity level: "debug", "info", "warn"
+processors = 6 # number of processors to use on parallel runs
 seed = 1 # seed value for the RNG (0 -> random value)
 # dates to start and end the simulation
 startdate = 2022-02-01