bazel syntax: disentangle StarlarkThread and Module

New interpreter API (if you don't use a helper function):

To call a function:

   try (Mutability mu = Mutability.create("myexpr")) {
      StarlarkThread thread = new StarlarkThread(mu, semantics);
      return Starlark.call(thread, fn, args, kwargs);
   } catch (EvalException ex) {
       ...
   }

To execute a file:

   StarlarkFile file = ...
   Module module = Module.create(); // default environment
   Resolver.resolve(file, module);
   try (Mutability mu = Mutability.create("myfile")) {
      StarlarkThread thread = new StarlarkThread(mu, semantics);
      Starlark.exec(file, thread, module);
   } catch (EvalException ex) {
       ...
   }
   // Inv: module contains globals

Overview of change:

- Eliminate the concept of "a Starlark thread's module".
  A module is rightly associated with a Starlark function, not a thread.
  Consider a thread used just to call an existing function value, for example.
  (Previously, a module would always have been created even if unused.)

- Modules are now created explicitly, from a predeclared environment
  and a semantics, which is used for filtering but not retained.
  Modules can now be created before threads---the logical order.
  This simplifies a number of clients.

- Flatten Module. It is no longer a linked list. It contains only
   (predeclared, globals, clientData),
  and exportedGlobals which will go away soon.

- Simplify processing of FlagGuardedValues. They are either unwrapped
  (if enabled by semantics) or left as is, if disabled.
  This means they are visible through Module.getPredeclared.

- Delete Module.mutability. It is inessential and raises
  questions of consistency with StarlarkThread.
  What really matters is whether a module's global values are mutable.

- Delete StarlarkThread.Builder. A simple constructor now suffices:
   new StarlarkThread(Mutability, StarlarkSemantics).

- EvaluationTestCase now exposes two hooks for Module and Thread creation
  so that tests can predeclare bindings, set client data, and insert
  thread local values.  Creation of Module and Thread is now fully lazy.
  A follow-up change will eliminate the regrettable use of inheritance.

Also:

- Move ModuleCodec into Module, so that we don't need to harm its API.

- Use separate UNIVERSE and predeclared buckets in Module.
  The UNIVERSE is always implicitly available.
  The API doesn't fully separate them yet (needs Resolver work),
  but this should reduce the amount of map copying and redundant
  specification.

- Add more pre-evaluated expressions to ParamDescriptor.evalDefault
  so that we can bootstrap all the @Param annotation's default values
  used by Starlark.UNIVERSE without JVM deadlock. This breaks a cyclic
  dependency between the evaluator and UNIVERSE.

- Use composition not inheritance of EvaluationTestCase in more tests.

This is my 6th attempt at this change in as many months.

This is a breaking API change for Copybara.

PiperOrigin-RevId: 312284294
diff --git a/src/test/java/com/google/devtools/build/lib/bazel/repository/skylark/StarlarkRepositoryContextTest.java b/src/test/java/com/google/devtools/build/lib/bazel/repository/skylark/StarlarkRepositoryContextTest.java
index 193d20b..537fb8c 100644
--- a/src/test/java/com/google/devtools/build/lib/bazel/repository/skylark/StarlarkRepositoryContextTest.java
+++ b/src/test/java/com/google/devtools/build/lib/bazel/repository/skylark/StarlarkRepositoryContextTest.java
@@ -79,8 +79,8 @@
   private Root root;
   private Path workspaceFile;
   private StarlarkRepositoryContext context;
-  private StarlarkThread thread =
-      StarlarkThread.builder(Mutability.create("test")).useDefaultSemantics().build();
+  private static final StarlarkThread thread =
+      new StarlarkThread(Mutability.create("test"), StarlarkSemantics.DEFAULT);
 
   private static String ONE_LINE_PATCH = "@@ -1,1 +1,2 @@\n line one\n+line two\n";
 
@@ -105,10 +105,9 @@
   }
 
   private static Object execAndEval(String... lines) {
-    try (Mutability mu = Mutability.create("impl")) {
-      StarlarkThread thread = StarlarkThread.builder(mu).useDefaultSemantics().build();
-      Module module = thread.getGlobals();
-      return EvalUtils.exec(ParserInput.fromLines(lines), FileOptions.DEFAULT, module, thread);
+    try {
+      return EvalUtils.exec(
+          ParserInput.fromLines(lines), FileOptions.DEFAULT, Module.create(), thread);
     } catch (Exception ex) { // SyntaxError | EvalException | InterruptedException
       throw new AssertionError("exec failed", ex);
     }
diff --git a/src/test/java/com/google/devtools/build/lib/packages/SelectTest.java b/src/test/java/com/google/devtools/build/lib/packages/SelectTest.java
index 7bec95b..89e4dc8 100644
--- a/src/test/java/com/google/devtools/build/lib/packages/SelectTest.java
+++ b/src/test/java/com/google/devtools/build/lib/packages/SelectTest.java
@@ -23,6 +23,7 @@
 import com.google.devtools.build.lib.syntax.Module;
 import com.google.devtools.build.lib.syntax.Mutability;
 import com.google.devtools.build.lib.syntax.ParserInput;
+import com.google.devtools.build.lib.syntax.StarlarkSemantics;
 import com.google.devtools.build.lib.syntax.StarlarkThread;
 import com.google.devtools.build.lib.syntax.SyntaxError;
 import java.util.List;
@@ -37,13 +38,12 @@
   private static Object eval(String expr)
       throws SyntaxError.Exception, EvalException, InterruptedException {
     ParserInput input = ParserInput.fromLines(expr);
-    StarlarkThread thread =
-        StarlarkThread.builder(Mutability.create("test"))
-            .setGlobals(Module.createForBuiltins(StarlarkLibrary.COMMON)) // select et al
-            .useDefaultSemantics()
-            .build();
-    Module module = thread.getGlobals();
-    return EvalUtils.eval(input, FileOptions.DEFAULT, module, thread);
+    Module module =
+        Module.withPredeclared(StarlarkSemantics.DEFAULT, /*predeclared=*/ StarlarkLibrary.COMMON);
+    try (Mutability mu = Mutability.create()) {
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+      return EvalUtils.eval(input, FileOptions.DEFAULT, module, thread);
+    }
   }
 
   private static void assertFails(String expr, String wantError) {
diff --git a/src/test/java/com/google/devtools/build/lib/packages/StarlarkProviderTest.java b/src/test/java/com/google/devtools/build/lib/packages/StarlarkProviderTest.java
index 3f0592c..f44f6a7 100644
--- a/src/test/java/com/google/devtools/build/lib/packages/StarlarkProviderTest.java
+++ b/src/test/java/com/google/devtools/build/lib/packages/StarlarkProviderTest.java
@@ -23,6 +23,7 @@
 import com.google.devtools.build.lib.cmdline.Label;
 import com.google.devtools.build.lib.syntax.Mutability;
 import com.google.devtools.build.lib.syntax.Starlark;
+import com.google.devtools.build.lib.syntax.StarlarkSemantics;
 import com.google.devtools.build.lib.syntax.StarlarkThread;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -132,16 +133,17 @@
 
   /** Instantiates a {@link StarlarkInfo} with fields a=1, b=2, c=3 (and nothing else). */
   private static StarlarkInfo instantiateWithA1B2C3(StarlarkProvider provider) throws Exception {
-    StarlarkThread thread =
-        StarlarkThread.builder(Mutability.create("test")).useDefaultSemantics().build();
-    Object result =
-        Starlark.call(
-            thread,
-            provider,
-            /*args=*/ ImmutableList.of(),
-            /*kwargs=*/ ImmutableMap.of("a", 1, "b", 2, "c", 3));
-    assertThat(result).isInstanceOf(StarlarkInfo.class);
-    return (StarlarkInfo) result;
+    try (Mutability mu = Mutability.create()) {
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+      Object result =
+          Starlark.call(
+              thread,
+              provider,
+              /*args=*/ ImmutableList.of(),
+              /*kwargs=*/ ImmutableMap.of("a", 1, "b", 2, "c", 3));
+      assertThat(result).isInstanceOf(StarlarkInfo.class);
+      return (StarlarkInfo) result;
+    }
   }
 
   /** Asserts that a {@link StarlarkInfo} has fields a=1, b=2, c=3 (and nothing else). */
diff --git a/src/test/java/com/google/devtools/build/lib/profiler/memory/AllocationTrackerTest.java b/src/test/java/com/google/devtools/build/lib/profiler/memory/AllocationTrackerTest.java
index 8cb0d92..930cfb8 100644
--- a/src/test/java/com/google/devtools/build/lib/profiler/memory/AllocationTrackerTest.java
+++ b/src/test/java/com/google/devtools/build/lib/profiler/memory/AllocationTrackerTest.java
@@ -32,6 +32,7 @@
 import com.google.devtools.build.lib.syntax.ParserInput;
 import com.google.devtools.build.lib.syntax.Starlark;
 import com.google.devtools.build.lib.syntax.StarlarkCallable;
+import com.google.devtools.build.lib.syntax.StarlarkSemantics;
 import com.google.devtools.build.lib.syntax.StarlarkThread;
 import com.google.devtools.build.lib.syntax.SyntaxError;
 import com.google.devtools.build.lib.syntax.TokenKind;
@@ -191,18 +192,16 @@
   private void exec(String... lines)
       throws SyntaxError.Exception, EvalException, InterruptedException {
     ParserInput input = ParserInput.create(Joiner.on("\n").join(lines), "a.star");
-    Mutability mu = Mutability.create("test");
-    StarlarkThread thread =
-        StarlarkThread.builder(mu)
-            .useDefaultSemantics()
-            .setGlobals(
-                Module.createForBuiltins(
-                    ImmutableMap.of(
-                        "sample", new SamplerValue(),
-                        "myrule", new MyRuleFunction())))
-            .build();
-    Module module = thread.getGlobals();
-    EvalUtils.exec(input, FileOptions.DEFAULT, module, thread);
+    Module module =
+        Module.withPredeclared(
+            StarlarkSemantics.DEFAULT,
+            ImmutableMap.of(
+                "sample", new SamplerValue(),
+                "myrule", new MyRuleFunction()));
+    try (Mutability mu = Mutability.create("test")) {
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+      EvalUtils.exec(input, FileOptions.DEFAULT, module, thread);
+    }
   }
 
   // A fake Bazel rule. The allocation tracker reports retained memory broken down by rule class.
diff --git a/src/test/java/com/google/devtools/build/lib/skyframe/StarlarkImportLookupFunctionTest.java b/src/test/java/com/google/devtools/build/lib/skyframe/StarlarkImportLookupFunctionTest.java
index 840bc1f..131878e 100644
--- a/src/test/java/com/google/devtools/build/lib/skyframe/StarlarkImportLookupFunctionTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skyframe/StarlarkImportLookupFunctionTest.java
@@ -407,7 +407,7 @@
         SkyframeExecutorTestUtils.evaluate(
             getSkyframeExecutor(), starlarkImportLookupKey, /*keepGoing=*/ false, reporter);
 
-    assertThat(result.get(starlarkImportLookupKey).getModule().getExportedBindings())
+    assertThat(result.get(starlarkImportLookupKey).getModule().getGlobals())
         .containsEntry("a_symbol", 5);
   }
 
diff --git a/src/test/java/com/google/devtools/build/lib/skylark/StarlarkRuleClassFunctionsTest.java b/src/test/java/com/google/devtools/build/lib/skylark/StarlarkRuleClassFunctionsTest.java
index 4f67c08..c0bc673 100644
--- a/src/test/java/com/google/devtools/build/lib/skylark/StarlarkRuleClassFunctionsTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skylark/StarlarkRuleClassFunctionsTest.java
@@ -61,7 +61,6 @@
 import com.google.devtools.build.lib.syntax.ParserInput;
 import com.google.devtools.build.lib.syntax.StarlarkFile;
 import com.google.devtools.build.lib.syntax.StarlarkList;
-import com.google.devtools.build.lib.syntax.StarlarkThread;
 import com.google.devtools.build.lib.syntax.SyntaxError;
 import com.google.devtools.build.lib.syntax.Tuple;
 import com.google.devtools.build.lib.syntax.util.EvaluationTestCase;
@@ -772,13 +771,13 @@
 
   private static void evalAndExport(EvaluationTestCase ev, String... lines) throws Exception {
     ParserInput input = ParserInput.fromLines(lines);
-    StarlarkThread thread = ev.getStarlarkThread();
-    Module module = thread.getGlobals();
+    Module module = ev.getModule();
     StarlarkFile file = EvalUtils.parseAndValidate(input, FileOptions.DEFAULT, module);
     if (!file.ok()) {
       throw new SyntaxError.Exception(file.errors());
     }
-    StarlarkImportLookupFunction.execAndExport(file, FAKE_LABEL, ev.getEventHandler(), thread);
+    StarlarkImportLookupFunction.execAndExport(
+        file, FAKE_LABEL, ev.getEventHandler(), module, ev.getStarlarkThread());
   }
 
   @Test
@@ -1385,7 +1384,7 @@
     assertThat(EvalUtils.isImmutable(makeStruct("a", makeList(null)))).isTrue();
     assertThat(EvalUtils.isImmutable(makeBigStruct(null))).isTrue();
 
-    Mutability mu = ev.getStarlarkThread().mutability();
+    Mutability mu = Mutability.create("test");
     assertThat(EvalUtils.isImmutable(Tuple.<Object>of(makeList(mu)))).isFalse();
     assertThat(EvalUtils.isImmutable(makeStruct("a", makeList(mu)))).isFalse();
     assertThat(EvalUtils.isImmutable(makeBigStruct(mu))).isFalse();
diff --git a/src/test/java/com/google/devtools/build/lib/skylark/util/BazelEvaluationTestCase.java b/src/test/java/com/google/devtools/build/lib/skylark/util/BazelEvaluationTestCase.java
index 4f8f44f..212ff8d 100644
--- a/src/test/java/com/google/devtools/build/lib/skylark/util/BazelEvaluationTestCase.java
+++ b/src/test/java/com/google/devtools/build/lib/skylark/util/BazelEvaluationTestCase.java
@@ -18,15 +18,11 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.devtools.build.lib.analysis.skylark.StarlarkModules;
 import com.google.devtools.build.lib.cmdline.Label;
-import com.google.devtools.build.lib.events.Event;
 import com.google.devtools.build.lib.packages.BazelModuleContext;
 import com.google.devtools.build.lib.packages.BazelStarlarkContext;
 import com.google.devtools.build.lib.packages.SymbolGenerator;
 import com.google.devtools.build.lib.rules.platform.PlatformCommon;
-import com.google.devtools.build.lib.syntax.Module;
-import com.google.devtools.build.lib.syntax.Mutability;
 import com.google.devtools.build.lib.syntax.Starlark;
-import com.google.devtools.build.lib.syntax.StarlarkSemantics;
 import com.google.devtools.build.lib.syntax.StarlarkThread;
 import com.google.devtools.build.lib.syntax.util.EvaluationTestCase;
 import com.google.devtools.build.lib.testutil.TestConstants;
@@ -39,28 +35,26 @@
 // Once the production code API has disentangled Thread and Module, make this rational.
 public final class BazelEvaluationTestCase extends EvaluationTestCase {
 
-  // Caution: called by the base class constructor.
   @Override
-  protected StarlarkThread newStarlarkThread(StarlarkSemantics semantics) {
-    ImmutableMap.Builder<String, Object> env = ImmutableMap.builder();
-    StarlarkModules.addStarlarkGlobalsToBuilder(env);
-    Starlark.addModule(env, new PlatformCommon());
+  protected Object newModuleHook(ImmutableMap.Builder<String, Object> predeclared) {
+    StarlarkModules.addStarlarkGlobalsToBuilder(predeclared);
+    Starlark.addModule(predeclared, new PlatformCommon());
 
-    StarlarkThread thread =
-        StarlarkThread.builder(Mutability.create("test"))
-            .setSemantics(semantics)
-            .setGlobals(
-                Module.createForBuiltins(env.build())
-                    .withClientData(
-                        BazelModuleContext.create(
-                            Label.parseAbsoluteUnchecked("//test:label", /*defaultToMain=*/ false),
-                            /*bzlTransitiveDigest=*/ new byte[0]))) // dummy value for tests
-            .build();
-    thread.setPrintHandler(Event.makeDebugPrintHandler(getEventHandler()));
+    // Return the module's client data. (This one uses dummy values for tests.)
+    return BazelModuleContext.create(
+        Label.parseAbsoluteUnchecked("//test:label", /*defaultToMain=*/ false),
+        /*bzlTransitiveDigest=*/ new byte[0]);
+  }
 
+  @Override
+  protected void newThreadHook(StarlarkThread thread) {
     // This StarlarkThread has no PackageContext, so attempts to create a rule will fail.
     // Rule creation is tested by StarlarkIntegrationTest.
 
+    // This is a poor approximation to the thread that Blaze would create
+    // for testing rule implementation functions. It has phase LOADING, for example.
+    // TODO(adonovan): stop creating threads in tests. This is the responsibility of the
+    // production code. Tests should provide only files and commands.
     new BazelStarlarkContext(
             BazelStarlarkContext.Phase.LOADING,
             TestConstants.TOOLS_REPOSITORY,
@@ -69,7 +63,5 @@
             new SymbolGenerator<>(new Object()),
             /*analysisRuleLabel=*/ null) // dummy value for tests
         .storeInThread(thread);
-
-    return thread;
   }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/starlarkdebug/server/BUILD b/src/test/java/com/google/devtools/build/lib/starlarkdebug/server/BUILD
index 8a8f3c9..8d015bc 100644
--- a/src/test/java/com/google/devtools/build/lib/starlarkdebug/server/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/starlarkdebug/server/BUILD
@@ -34,6 +34,7 @@
         "//src/main/java/com/google/devtools/build/lib/starlarkdebug/server",
         "//src/test/java/com/google/devtools/build/lib/events:testutil",
         "//third_party:guava",
+        "//third_party:jsr305",
         "//third_party:junit4",
         "//third_party:truth",
     ],
diff --git a/src/test/java/com/google/devtools/build/lib/starlarkdebug/server/StarlarkDebugServerTest.java b/src/test/java/com/google/devtools/build/lib/starlarkdebug/server/StarlarkDebugServerTest.java
index 35fee1d..a481c3a 100644
--- a/src/test/java/com/google/devtools/build/lib/starlarkdebug/server/StarlarkDebugServerTest.java
+++ b/src/test/java/com/google/devtools/build/lib/starlarkdebug/server/StarlarkDebugServerTest.java
@@ -42,10 +42,12 @@
 import com.google.devtools.build.lib.syntax.EvalException;
 import com.google.devtools.build.lib.syntax.EvalUtils;
 import com.google.devtools.build.lib.syntax.FileOptions;
+import com.google.devtools.build.lib.syntax.Module;
 import com.google.devtools.build.lib.syntax.Mutability;
 import com.google.devtools.build.lib.syntax.ParserInput;
 import com.google.devtools.build.lib.syntax.Starlark;
 import com.google.devtools.build.lib.syntax.StarlarkList;
+import com.google.devtools.build.lib.syntax.StarlarkSemantics;
 import com.google.devtools.build.lib.syntax.StarlarkThread;
 import com.google.devtools.build.lib.syntax.SyntaxError;
 import java.io.IOException;
@@ -60,6 +62,7 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
+import javax.annotation.Nullable;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -150,9 +153,8 @@
   @Test
   public void testPausedUntilStartDebuggingRequestReceived() throws Exception {
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]");
-    StarlarkThread thread = newStarlarkThread();
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     String threadName = evaluationThread.getName();
     long threadId = evaluationThread.getId();
 
@@ -189,8 +191,8 @@
     setBreakpoints(ImmutableList.of(breakpoint));
 
     // evaluate in two separate worker threads
-    execInWorkerThread(buildFile, newStarlarkThread());
-    execInWorkerThread(buildFile, newStarlarkThread());
+    execInWorkerThread(buildFile, null);
+    execInWorkerThread(buildFile, null);
 
     // wait for both breakpoints to be hit
     boolean paused =
@@ -218,13 +220,12 @@
   public void testPauseAtBreakpoint() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     String threadName = evaluationThread.getName();
     long threadId = evaluationThread.getId();
 
@@ -247,7 +248,6 @@
     sendStartDebuggingRequest();
     ParserInput buildFile =
         createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]", "z = 1");
-    StarlarkThread thread = newStarlarkThread();
 
     ImmutableList<Breakpoint> breakpoints =
         ImmutableList.of(
@@ -261,7 +261,7 @@
                 .build());
     setBreakpoints(breakpoints);
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     String threadName = evaluationThread.getName();
     long threadId = evaluationThread.getId();
     Breakpoint expectedBreakpoint = breakpoints.get(1);
@@ -282,7 +282,6 @@
   public void testPauseAtSatisfiedConditionalBreakpoint() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location location =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
@@ -290,7 +289,7 @@
         Breakpoint.newBuilder().setLocation(location).setExpression("x[0] == 1").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     String threadName = evaluationThread.getName();
     long threadId = evaluationThread.getId();
 
@@ -312,7 +311,6 @@
   public void testPauseAtInvalidConditionBreakpointWithError() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location location =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
@@ -320,7 +318,7 @@
         Breakpoint.newBuilder().setLocation(location).setExpression("z[0] == 1").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     String threadName = evaluationThread.getName();
     long threadId = evaluationThread.getId();
 
@@ -357,13 +355,12 @@
   public void testSimpleListFramesRequest() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -387,13 +384,12 @@
   public void testGetChildrenRequest() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -426,13 +422,13 @@
             "  b = 1",
             "  b + 1",
             "fn()");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setPath("/a/build/file/test.bzl").setLineNumber(6).build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(bzlFile, thread);
+    Module module = Module.create();
+    Thread evaluationThread = execInWorkerThread(bzlFile, module);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -455,7 +451,7 @@
                 Scope.newBuilder()
                     .setName("global")
                     .addBinding(getValueProto("c", 3))
-                    .addBinding(getValueProto("fn", thread.getGlobals().lookup("fn"))))
+                    .addBinding(getValueProto("fn", module.getGlobal("fn"))))
             .build());
 
     assertFramesEqualIgnoringValueIdentifiers(
@@ -472,7 +468,7 @@
                     .setName("global")
                     .addBinding(getValueProto("a", 1))
                     .addBinding(getValueProto("c", 3))
-                    .addBinding(getValueProto("fn", thread.getGlobals().lookup("fn"))))
+                    .addBinding(getValueProto("fn", module.getGlobal("fn"))))
             .build());
   }
 
@@ -480,13 +476,12 @@
   public void testEvaluateRequestWithExpression() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -507,13 +502,12 @@
   public void testEvaluateRequestWithAssignmentStatement() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -541,13 +535,12 @@
   public void testEvaluateRequestWithExpressionStatementMutatingState() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -575,13 +568,12 @@
   public void testEvaluateRequestThrowingException() throws Exception {
     sendStartDebuggingRequest();
     ParserInput buildFile = createInput("/a/build/file/BUILD", "x = [1,2,3]", "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/BUILD").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(buildFile, thread);
+    Thread evaluationThread = execInWorkerThread(buildFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -609,13 +601,12 @@
             "  return a",
             "x = fn()",
             "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(4).setPath("/a/build/file/test.bzl").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(bzlFile, thread);
+    Thread evaluationThread = execInWorkerThread(bzlFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -657,13 +648,12 @@
             "  return a",
             "x = fn()",
             "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(4).setPath("/a/build/file/test.bzl").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(bzlFile, thread);
+    Thread evaluationThread = execInWorkerThread(bzlFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -700,13 +690,12 @@
             "  return a",
             "x = fn()",
             "y = [2,3,4]");
-    StarlarkThread thread = newStarlarkThread();
 
     Location breakpoint =
         Location.newBuilder().setLineNumber(2).setPath("/a/build/file/test.bzl").build();
     setBreakpoints(ImmutableList.of(breakpoint));
 
-    Thread evaluationThread = execInWorkerThread(bzlFile, thread);
+    Thread evaluationThread = execInWorkerThread(bzlFile, null);
     long threadId = evaluationThread.getId();
 
     // wait for breakpoint to be hit
@@ -772,25 +761,23 @@
     return event.getListFrames();
   }
 
-  private static StarlarkThread newStarlarkThread() {
-    Mutability mutability = Mutability.create("test");
-    return StarlarkThread.builder(mutability).useDefaultSemantics().build();
-  }
-
   private static ParserInput createInput(String filename, String... lines) {
     return ParserInput.create(Joiner.on("\n").join(lines), filename);
   }
 
   /**
-   * Creates and starts a worker thread parsing, resolving, and executing the given Starlark file in
-   * the given environment.
+   * Creates and starts a worker thread parsing, resolving, and executing the given Starlark file to
+   * populate the specified module, or if none is given, in a fresh module with a default
+   * environment.
    */
-  private static Thread execInWorkerThread(ParserInput input, StarlarkThread thread) {
+  private static Thread execInWorkerThread(ParserInput input, @Nullable Module module) {
     Thread javaThread =
         new Thread(
             () -> {
-              try {
-                EvalUtils.exec(input, FileOptions.DEFAULT, thread.getGlobals(), thread);
+              try (Mutability mu = Mutability.create("test")) {
+                StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+                EvalUtils.exec(
+                    input, FileOptions.DEFAULT, module != null ? module : Module.create(), thread);
               } catch (SyntaxError.Exception | EvalException | InterruptedException ex) {
                 throw new AssertionError(ex);
               }
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/CpuProfilerTest.java b/src/test/java/com/google/devtools/build/lib/syntax/CpuProfilerTest.java
index 599fd40..c945dfb 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/CpuProfilerTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/CpuProfilerTest.java
@@ -62,12 +62,11 @@
             "f()");
 
     // Execute the workload.
-    StarlarkThread thread =
-        StarlarkThread.builder(Mutability.create("test"))
-            .setGlobals(Module.createForBuiltins(Starlark.UNIVERSE))
-            .useDefaultSemantics()
-            .build();
-    EvalUtils.exec(input, FileOptions.DEFAULT, thread.getGlobals(), thread);
+    Module module = Module.create();
+    try (Mutability mu = Mutability.create("test")) {
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+      EvalUtils.exec(input, FileOptions.DEFAULT, module, thread);
+    }
 
     Starlark.stopCpuProfile();
 
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/EvaluationTest.java b/src/test/java/com/google/devtools/build/lib/syntax/EvaluationTest.java
index 9bcd35c..0f6534b 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/EvaluationTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/EvaluationTest.java
@@ -29,18 +29,16 @@
 // TODO(adonovan): separate tests of parser, resolver, Starlark core evaluator,
 // and BUILD and .bzl features.
 @RunWith(JUnit4.class)
-public final class EvaluationTest extends EvaluationTestCase {
+public final class EvaluationTest {
+
+  private final EvaluationTestCase ev = new EvaluationTestCase();
 
   @Test
   public void testExecutionStopsAtFirstError() throws Exception {
     List<String> printEvents = new ArrayList<>();
-    StarlarkThread thread =
-        createStarlarkThread(/*printHandler=*/ (_thread, msg) -> printEvents.add(msg));
     ParserInput input = ParserInput.fromLines("print('hello'); x = 1//0; print('goodbye')");
-
-    Module module = thread.getGlobals();
-    assertThrows(
-        EvalException.class, () -> EvalUtils.exec(input, FileOptions.DEFAULT, module, thread));
+    InterruptFunction interrupt = new InterruptFunction();
+    assertThrows(EvalException.class, () -> execWithInterrupt(input, interrupt, printEvents));
 
     // Only expect hello, should have been an error before goodbye.
     assertThat(printEvents.toString()).isEqualTo("[hello]");
@@ -48,93 +46,52 @@
 
   @Test
   public void testExecutionNotStartedOnInterrupt() throws Exception {
-    StarlarkThread thread =
-        createStarlarkThread(
-            /*printHandler=*/ (_thread, msg) -> {
-              throw new AssertionError("print statement was reached");
-            });
-    ParserInput input = ParserInput.fromLines("print('hello');");
-    Module module = thread.getGlobals();
+    ParserInput input = ParserInput.fromLines("print('hello')");
+    List<String> printEvents = new ArrayList<>();
+    Thread.currentThread().interrupt();
+    InterruptFunction interrupt = new InterruptFunction();
+    assertThrows(
+        InterruptedException.class, () -> execWithInterrupt(input, interrupt, printEvents));
 
-    try {
-      Thread.currentThread().interrupt();
-      assertThrows(
-          InterruptedException.class,
-          () -> EvalUtils.exec(input, FileOptions.DEFAULT, module, thread));
-    } finally {
-      // Reset interrupt bit in case the test failed to do so.
-      Thread.interrupted();
-    }
+    // Execution didn't reach print.
+    assertThat(printEvents).isEmpty();
   }
 
   @Test
   public void testForLoopAbortedOnInterrupt() throws Exception {
-    StarlarkThread thread = createStarlarkThread((th, msg) -> {});
-    InterruptFunction interruptFunction = new InterruptFunction();
-    Module module = thread.getGlobals();
-    module.put("interrupt", interruptFunction);
-
     ParserInput input =
         ParserInput.fromLines(
-            "def foo():", // Can't declare for loops at top level, so wrap with a function.
+            "def f():", //
             "  for i in range(100):",
             "    interrupt(i == 5)",
-            "foo()");
+            "f()");
+    InterruptFunction interrupt = new InterruptFunction();
+    assertThrows(
+        InterruptedException.class, () -> execWithInterrupt(input, interrupt, new ArrayList<>()));
 
-    try {
-      assertThrows(
-          InterruptedException.class,
-          () -> EvalUtils.exec(input, FileOptions.DEFAULT, module, thread));
-    } finally {
-      // Reset interrupt bit in case the test failed to do so.
-      Thread.interrupted();
-    }
-
-    assertThat(interruptFunction.callCount).isEqualTo(6);
+    assertThat(interrupt.callCount).isEqualTo(6);
   }
 
   @Test
   public void testForComprehensionAbortedOnInterrupt() throws Exception {
-    StarlarkThread thread = createStarlarkThread((th, msg) -> {});
-    Module module = thread.getGlobals();
-    InterruptFunction interruptFunction = new InterruptFunction();
-    module.put("interrupt", interruptFunction);
-
     ParserInput input = ParserInput.fromLines("[interrupt(i == 5) for i in range(100)]");
+    InterruptFunction interrupt = new InterruptFunction();
+    assertThrows(
+        InterruptedException.class, () -> execWithInterrupt(input, interrupt, new ArrayList<>()));
 
-    try {
-      assertThrows(
-          InterruptedException.class,
-          () -> EvalUtils.exec(input, FileOptions.DEFAULT, module, thread));
-    } finally {
-      // Reset interrupt bit in case the test failed to do so.
-      Thread.interrupted();
-    }
-
-    assertThat(interruptFunction.callCount).isEqualTo(6);
+    assertThat(interrupt.callCount).isEqualTo(6);
   }
 
   @Test
   public void testFunctionCallsNotStartedOnInterrupt() throws Exception {
-    StarlarkThread thread = createStarlarkThread((th, msg) -> {});
-    Module module = thread.getGlobals();
-    InterruptFunction interruptFunction = new InterruptFunction();
-    module.put("interrupt", interruptFunction);
-
     ParserInput input =
         ParserInput.fromLines("interrupt(False); interrupt(True); interrupt(False);");
-
-    try {
-      assertThrows(
-          InterruptedException.class,
-          () -> EvalUtils.exec(input, FileOptions.DEFAULT, module, thread));
-    } finally {
-      // Reset interrupt bit in case the test failed to do so.
-      Thread.interrupted();
-    }
+    InterruptFunction interrupt = new InterruptFunction();
+    assertThrows(
+        InterruptedException.class, () -> execWithInterrupt(input, interrupt, new ArrayList<>()));
 
     // Third call shouldn't happen.
-    assertThat(interruptFunction.callCount).isEqualTo(2);
+    assertThat(interrupt.callCount).isEqualTo(2);
   }
 
   private static class InterruptFunction implements StarlarkCallable {
@@ -156,21 +113,25 @@
     }
   }
 
-  private static StarlarkThread createStarlarkThread(StarlarkThread.PrintHandler printHandler) {
-    Mutability mu = Mutability.create("test");
-    StarlarkThread thread =
-        StarlarkThread.builder(mu)
-            .useDefaultSemantics()
-            // Provide the UNIVERSE for print... this should not be necessary
-            .setGlobals(Module.createForBuiltins(Starlark.UNIVERSE))
-            .build();
-    thread.setPrintHandler(printHandler);
-    return thread;
+  // Executes input, with the specified 'interrupt' predeclared built-in, gather print events in
+  // printEvents.
+  private static void execWithInterrupt(
+      ParserInput input, InterruptFunction interrupt, List<String> printEvents) throws Exception {
+    Module module =
+        Module.withPredeclared(StarlarkSemantics.DEFAULT, ImmutableMap.of("interrupt", interrupt));
+    try (Mutability mu = Mutability.create("test")) {
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+      thread.setPrintHandler((_thread, msg) -> printEvents.add(msg));
+      EvalUtils.exec(input, FileOptions.DEFAULT, module, thread);
+    } finally {
+      // Reset interrupt bit in case the test failed to do so.
+      Thread.interrupted();
+    }
   }
 
   @Test
   public void testExprs() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("'%sx' % 'foo' + 'bar1'", "fooxbar1")
         .testExpression("('%sx' % 'foo') + 'bar2'", "fooxbar2")
         .testExpression("'%sx' % ('foo' + 'bar3')", "foobar3x")
@@ -184,17 +145,17 @@
 
   @Test
   public void testListExprs() throws Exception {
-    new Scenario().testExactOrder("[1, 2, 3]", 1, 2, 3).testExactOrder("(1, 2, 3)", 1, 2, 3);
+    ev.new Scenario().testExactOrder("[1, 2, 3]", 1, 2, 3).testExactOrder("(1, 2, 3)", 1, 2, 3);
   }
 
   @Test
   public void testStringFormatMultipleArgs() throws Exception {
-    new Scenario().testExpression("'%sY%s' % ('X', 'Z')", "XYZ");
+    ev.new Scenario().testExpression("'%sY%s' % ('X', 'Z')", "XYZ");
   }
 
   @Test
   public void testConditionalExpressions() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("1 if True else 2", 1)
         .testExpression("1 if False else 2", 2)
         .testExpression("1 + 2 if 3 + 4 else 5 + 6", 3);
@@ -202,7 +163,7 @@
 
   @Test
   public void testListComparison() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("[] < [1]", true)
         .testExpression("[1] < [1, 1]", true)
         .testExpression("[1, 1] < [1, 2]", true)
@@ -239,7 +200,7 @@
           }
         };
 
-    new Scenario()
+    ev.new Scenario()
         .update(sum.getName(), sum)
         .testExpression("sum(1, 2, 3, 4, 5, 6)", 21)
         .testExpression("sum", sum)
@@ -248,7 +209,7 @@
 
   @Test
   public void testNotCallInt() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("sum = 123456")
         .testLookup("sum", 123456)
         .testIfExactError("'int' object is not callable", "sum(1, 2, 3, 4, 5, 6)")
@@ -257,7 +218,7 @@
 
   @Test
   public void testComplexFunctionCall() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("functions = [min, max]", "l = [1,2]")
         .testEval("(functions[0](l), functions[1](l))", "(1, 2)");
   }
@@ -281,7 +242,7 @@
           }
         };
 
-    new Scenario()
+    ev.new Scenario()
         .update(kwargs.getName(), kwargs)
         .testEval(
             "kwargs(foo=1, bar='bar', wiz=[1,2,3]).items()",
@@ -293,7 +254,7 @@
 
   @Test
   public void testModulo() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("6 % 2", 0)
         .testExpression("6 % 4", 2)
         .testExpression("3 % 6", 3)
@@ -305,7 +266,7 @@
 
   @Test
   public void testMult() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("6 * 7", 42)
         .testExpression("3 * 'ab'", "ababab")
         .testExpression("0 * 'ab'", "")
@@ -316,12 +277,12 @@
 
   @Test
   public void testSlashOperatorIsForbidden() throws Exception {
-    new Scenario().testIfErrorContains("The `/` operator is not allowed.", "5 / 2");
+    ev.new Scenario().testIfErrorContains("The `/` operator is not allowed.", "5 / 2");
   }
 
   @Test
   public void testFloorDivision() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("6 // 2", 3)
         .testExpression("6 // 4", 1)
         .testExpression("3 // 6", 0)
@@ -335,7 +296,7 @@
 
   @Test
   public void testCheckedArithmetic() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains("integer overflow", "2000000000 + 2000000000")
         .testIfErrorContains("integer overflow", "1234567890 * 987654321")
         .testIfErrorContains("integer overflow", "- 2000000000 - 2000000000")
@@ -347,7 +308,7 @@
 
   @Test
   public void testOperatorPrecedence() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("2 + 3 * 4", 14)
         .testExpression("2 + 3 // 4", 2)
         .testExpression("2 * 3 + 4 // -2", 4);
@@ -355,36 +316,36 @@
 
   @Test
   public void testConcatStrings() throws Exception {
-    new Scenario().testExpression("'foo' + 'bar'", "foobar");
+    ev.new Scenario().testExpression("'foo' + 'bar'", "foobar");
   }
 
   @Test
   public void testConcatLists() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExactOrder("[1,2] + [3,4]", 1, 2, 3, 4)
         .testExactOrder("(1,2)", 1, 2)
         .testExactOrder("(1,2) + (3,4)", 1, 2, 3, 4);
 
     // TODO(fwe): cannot be handled by current testing suite
     // list
-    Object x = eval("[1,2] + [3,4]");
+    Object x = ev.eval("[1,2] + [3,4]");
     assertThat((Iterable<?>) x).containsExactly(1, 2, 3, 4).inOrder();
     assertThat(x).isInstanceOf(StarlarkList.class);
     assertThat(EvalUtils.isImmutable(x)).isFalse();
 
     // tuple
-    x = eval("(1,2) + (3,4)");
+    x = ev.eval("(1,2) + (3,4)");
     assertThat((Iterable<?>) x).containsExactly(1, 2, 3, 4).inOrder();
     assertThat(x).isInstanceOf(Tuple.class);
     assertThat(x).isEqualTo(Tuple.of(1, 2, 3, 4));
     assertThat(EvalUtils.isImmutable(x)).isTrue();
 
-    checkEvalError("unsupported binary operation: tuple + list", "(1,2) + [3,4]");
+    ev.checkEvalError("unsupported binary operation: tuple + list", "(1,2) + [3,4]");
   }
 
   @Test
   public void testListComprehensions() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExactOrder("['foo/%s.java' % x for x in []]")
         .testExactOrder(
             "['foo/%s.java' % y for y in ['bar', 'wiz', 'quux']]",
@@ -426,10 +387,10 @@
 
   @Test
   public void testNestedListComprehensions() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("li = [[1, 2], [3, 4]]")
         .testExactOrder("[j for i in li for j in i]", 1, 2, 3, 4);
-    new Scenario()
+    ev.new Scenario()
         .setUp("input = [['abc'], ['def', 'ghi']]\n")
         .testExactOrder(
             "['%s %s' % (b, c) for a in input for b in a for c in b.elems()]",
@@ -438,7 +399,7 @@
 
   @Test
   public void testListComprehensionsMultipleVariables() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testEval("[x + y for x, y in [(1, 2), (3, 4)]]", "[3, 7]")
         .testEval("[z + t for (z, t) in [[1, 2], [3, 4]]]", "[3, 7]");
   }
@@ -447,52 +408,52 @@
   public void testSequenceAssignment() throws Exception {
     // assignment to empty list/tuple
     // See https://github.com/bazelbuild/starlark/issues/93 for discussion
-    checkEvalError(
+    ev.checkEvalError(
         "can't assign to ()", //
         "() = ()");
-    checkEvalError(
+    ev.checkEvalError(
         "can't assign to ()", //
         "() = 1");
-    checkEvalError(
+    ev.checkEvalError(
         "can't assign to []", //
         "[] = ()");
 
     // RHS not iterable
-    checkEvalError(
+    ev.checkEvalError(
         "got 'int' in sequence assignment", //
         "x, y = 1");
-    checkEvalError(
+    ev.checkEvalError(
         "got 'int' in sequence assignment", //
         "(x,) = 1");
-    checkEvalError(
+    ev.checkEvalError(
         "got 'int' in sequence assignment", //
         "[x] = 1");
 
     // too few
-    checkEvalError(
+    ev.checkEvalError(
         "too few values to unpack (got 0, want 2)", //
         "x, y = ()");
-    checkEvalError(
+    ev.checkEvalError(
         "too few values to unpack (got 0, want 2)", //
         "[x, y] = ()");
 
     // just right
-    exec("x, y = 1, 2");
-    exec("[x, y] = 1, 2");
-    exec("(x,) = [1]");
+    ev.exec("x, y = 1, 2");
+    ev.exec("[x, y] = 1, 2");
+    ev.exec("(x,) = [1]");
 
     // too many
-    checkEvalError(
+    ev.checkEvalError(
         "too many values to unpack (got 3, want 2)", //
         "x, y = 1, 2, 3");
-    checkEvalError(
+    ev.checkEvalError(
         "too many values to unpack (got 3, want 2)", //
         "[x, y] = 1, 2, 3");
   }
 
   @Test
   public void testListComprehensionsMultipleVariablesFail() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "too few values to unpack (got 2, want 3)", //
             "[x + y for x, y, z in [(1, 2), (3, 4)]]")
@@ -500,29 +461,29 @@
             "got 'int' in sequence assignment", //
             "[x + y for x, y in (1, 2)]");
 
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "too few values to unpack (got 2, want 3)", //
             "def foo (): return [x + y for x, y, z in [(1, 2), (3, 4)]]",
             "foo()");
 
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "got 'int' in sequence assignment", //
             "def bar (): return [x + y for x, y in (1, 2)]",
             "bar()");
 
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "too few values to unpack (got 2, want 3)", //
             "[x + y for x, y, z in [(1, 2), (3, 4)]]");
 
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "got 'int' in sequence assignment", //
             "[x2 + y2 for x2, y2 in (1, 2)]");
 
-    new Scenario()
+    ev.new Scenario()
         // Behavior varies across Python2 and 3 and Starlark in {Go,Java}.
         // See https://github.com/bazelbuild/starlark/issues/93 for discussion.
         .testIfErrorContains(
@@ -532,7 +493,7 @@
 
   @Test
   public void testListComprehensionsWithFiltering() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("range3 = [0, 1, 2]")
         .testEval("[a for a in (4, None, 2, None, 1) if a != None]", "[4, 2, 1]")
         .testEval("[b+c for b in [0, 1, 2] for c in [0, 1, 2] if b + c > 2]", "[3, 3, 4]")
@@ -548,7 +509,7 @@
     // This exercises the .bzl file behavior. This is a dynamic error.
     // (The error message for BUILD files is slightly different (no "local")
     // because it doesn't record the scope in the syntax tree.)
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "local variable 'y' is referenced before assignment", //
             "[x for x in (1, 2) if y for y in (3, 4)]");
@@ -563,16 +524,16 @@
   private static void execBUILD(String... lines)
       throws SyntaxError.Exception, EvalException, InterruptedException {
     ParserInput input = ParserInput.fromLines(lines);
-    StarlarkThread thread =
-        StarlarkThread.builder(Mutability.create("test")).useDefaultSemantics().build();
-    Module module = thread.getGlobals();
     FileOptions options = FileOptions.builder().recordScope(false).build();
-    EvalUtils.exec(input, options, module, thread);
+    try (Mutability mu = Mutability.create("test")) {
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+      EvalUtils.exec(input, options, Module.create(), thread);
+    }
   }
 
   @Test
   public void testTupleDestructuring() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("a, b = 1, 2")
         .testLookup("a", 1)
         .testLookup("b", 2)
@@ -583,12 +544,12 @@
 
   @Test
   public void testSingleTuple() throws Exception {
-    new Scenario().setUp("(a,) = [1]").testLookup("a", 1);
+    ev.new Scenario().setUp("(a,) = [1]").testLookup("a", 1);
   }
 
   @Test
   public void testHeterogeneousDict() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("d = {'str': 1, 2: 3}", "a = d['str']", "b = d[2]")
         .testLookup("a", 1)
         .testLookup("b", 3);
@@ -596,19 +557,19 @@
 
   @Test
   public void testAccessDictWithATupleKey() throws Exception {
-    new Scenario().setUp("x = {(1, 2): 3}[1, 2]").testLookup("x", 3);
+    ev.new Scenario().setUp("x = {(1, 2): 3}[1, 2]").testLookup("x", 3);
   }
 
   @Test
   public void testDictWithDuplicatedKey() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "Duplicated key \"str\" when creating dictionary", "{'str': 1, 'x': 2, 'str': 3}");
   }
 
   @Test
   public void testRecursiveTupleDestructuring() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("((a, b), (c, d)) = [(1, 2), (3, 4)]")
         .testLookup("a", 1)
         .testLookup("b", 2)
@@ -619,7 +580,7 @@
   @Test
   public void testListComprehensionAtTopLevel() throws Exception {
     // It is allowed to have a loop variable with the same name as a global variable.
-    new Scenario()
+    ev.new Scenario()
         .update("x", 42)
         .setUp("y = [x + 1 for x in [1,2,3]]")
         .testExactOrder("y", 2, 3, 4);
@@ -627,7 +588,7 @@
 
   @Test
   public void testDictComprehensions() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("{a : a for a in []}", Collections.emptyMap())
         .testExpression("{b : b for b in [1, 2]}", ImmutableMap.of(1, 1, 2, 2))
         .testExpression(
@@ -642,12 +603,12 @@
 
   @Test
   public void testDictComprehensionOnNonIterable() throws Exception {
-    new Scenario().testIfExactError("type 'int' is not iterable", "{k : k for k in 3}");
+    ev.new Scenario().testIfExactError("type 'int' is not iterable", "{k : k for k in 3}");
   }
 
   @Test
   public void testDictComprehension_ManyClauses() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression(
             "{x : x * y for x in range(1, 10) if x % 2 == 0 for y in range(1, 10) if y == x}",
             ImmutableMap.of(2, 4, 4, 16, 6, 36, 8, 64));
@@ -655,7 +616,7 @@
 
   @Test
   public void testDictComprehensions_MultipleKey() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("{x : x for x in [1, 2, 1]}", ImmutableMap.of(1, 1, 2, 2))
         .testExpression(
             "{y : y for y in ['ab', 'c', 'a' + 'b']}", ImmutableMap.of("ab", "ab", "c", "c"));
@@ -663,7 +624,7 @@
 
   @Test
   public void testListConcatenation() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("[1, 2] + [3, 4]", StarlarkList.of(null, 1, 2, 3, 4))
         .testExpression("(1, 2) + (3, 4)", Tuple.of(1, 2, 3, 4))
         .testIfExactError("unsupported binary operation: list + tuple", "[1, 2] + (3, 4)")
@@ -673,7 +634,7 @@
   @Test
   public void testListMultiply() throws Exception {
     Mutability mu = Mutability.create("test");
-    new Scenario()
+    ev.new Scenario()
         .testExpression("[1, 2, 3] * 1", StarlarkList.of(mu, 1, 2, 3))
         .testExpression("[1, 2] * 2", StarlarkList.of(mu, 1, 2, 1, 2))
         .testExpression("[1, 2] * 3", StarlarkList.of(mu, 1, 2, 1, 2, 1, 2))
@@ -690,7 +651,7 @@
 
   @Test
   public void testTupleMultiply() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("(1, 2, 3) * 1", Tuple.of(1, 2, 3))
         .testExpression("(1, 2) * 2", Tuple.of(1, 2, 1, 2))
         .testExpression("(1, 2) * 3", Tuple.of(1, 2, 1, 2, 1, 2))
@@ -707,34 +668,34 @@
 
   @Test
   public void testListComprehensionFailsOnNonSequence() throws Exception {
-    new Scenario().testIfErrorContains("type 'int' is not iterable", "[x + 1 for x in 123]");
+    ev.new Scenario().testIfErrorContains("type 'int' is not iterable", "[x + 1 for x in 123]");
   }
 
   @Test
   public void testListComprehensionOnStringIsForbidden() throws Exception {
-    new Scenario().testIfErrorContains("type 'string' is not iterable", "[x for x in 'abc']");
+    ev.new Scenario().testIfErrorContains("type 'string' is not iterable", "[x for x in 'abc']");
   }
 
   @Test
   public void testInvalidAssignment() throws Exception {
-    new Scenario().testIfErrorContains("cannot assign to 'x + 1'", "x + 1 = 2");
+    ev.new Scenario().testIfErrorContains("cannot assign to 'x + 1'", "x + 1 = 2");
   }
 
   @Test
   public void testListComprehensionOnDictionary() throws Exception {
-    new Scenario().testExactOrder("['var_' + n for n in {'a':1,'b':2}]", "var_a", "var_b");
+    ev.new Scenario().testExactOrder("['var_' + n for n in {'a':1,'b':2}]", "var_a", "var_b");
   }
 
   @Test
   public void testListComprehensionOnDictionaryCompositeExpression() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("d = {1:'a',2:'b'}", "l = [d[x] for x in d]")
         .testLookup("l", StarlarkList.of(null, "a", "b"));
   }
 
   @Test
   public void testListComprehensionUpdate() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("xs = [1, 2, 3]")
         .testIfErrorContains(
             "list value is temporarily immutable due to active for-loop iteration",
@@ -743,7 +704,7 @@
 
   @Test
   public void testNestedListComprehensionUpdate() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("xs = [1, 2, 3]")
         .testIfErrorContains(
             "list value is temporarily immutable due to active for-loop iteration",
@@ -752,7 +713,7 @@
 
   @Test
   public void testListComprehensionUpdateInClause() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("xs = [1, 2, 3]")
         .testIfErrorContains(
             "list value is temporarily immutable due to active for-loop iteration",
@@ -763,7 +724,7 @@
 
   @Test
   public void testDictComprehensionUpdate() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .setUp("xs = {1:1, 2:2, 3:3}")
         .testIfErrorContains(
             "dict value is temporarily immutable due to active for-loop iteration",
@@ -773,7 +734,7 @@
   @Test
   public void testListComprehensionScope() throws Exception {
     // Test list comprehension creates a scope, so outer variables kept unchanged
-    new Scenario()
+    ev.new Scenario()
         .setUp("x = 1", "l = [x * 3 for x in [2]]", "y = x")
         .testEval("y", "1")
         .testEval("l", "[6]");
@@ -781,7 +742,7 @@
 
   @Test
   public void testInOperator() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("'b' in ['a', 'b']", Boolean.TRUE)
         .testExpression("'c' in ['a', 'b']", Boolean.FALSE)
         .testExpression("'b' in ('a', 'b')", Boolean.TRUE)
@@ -795,7 +756,7 @@
 
   @Test
   public void testNotInOperator() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testExpression("'b' not in ['a', 'b']", Boolean.FALSE)
         .testExpression("'c' not in ['a', 'b']", Boolean.TRUE)
         .testExpression("'b' not in ('a', 'b')", Boolean.FALSE)
@@ -809,7 +770,7 @@
 
   @Test
   public void testInFail() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "'in <string>' requires string as left operand, not 'int'", "1 in '123'")
         .testIfErrorContains("unsupported binary operation: string in int", "'a' in 1");
@@ -817,10 +778,10 @@
 
   @Test
   public void testInCompositeForPrecedence() throws Exception {
-    new Scenario().testExpression("not 'a' in ['a'] or 0", 0);
+    ev.new Scenario().testExpression("not 'a' in ['a'] or 0", 0);
   }
 
-  private StarlarkValue createObjWithStr() {
+  private static StarlarkValue createObjWithStr() {
     return new StarlarkValue() {
       @Override
       public void repr(Printer printer) {
@@ -831,7 +792,9 @@
 
   @Test
   public void testPercentOnObjWithStr() throws Exception {
-    new Scenario().update("obj", createObjWithStr()).testExpression("'%s' % obj", "<str marker>");
+    ev.new Scenario()
+        .update("obj", createObjWithStr())
+        .testExpression("'%s' % obj", "<str marker>");
   }
 
   private static class Dummy implements StarlarkValue {}
@@ -839,7 +802,7 @@
   @Test
   public void testStringRepresentationsOfArbitraryObjects() throws Exception {
     String dummy = "<unknown object com.google.devtools.build.lib.syntax.EvaluationTest$Dummy>";
-    new Scenario()
+    ev.new Scenario()
         .update("dummy", new Dummy())
         .testExpression("str(dummy)", dummy)
         .testExpression("repr(dummy)", dummy)
@@ -850,10 +813,10 @@
 
   @Test
   public void testPercentOnTupleOfDummyValues() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .update("obj", createObjWithStr())
         .testExpression("'%s %s' % (obj, obj)", "<str marker> <str marker>");
-    new Scenario()
+    ev.new Scenario()
         .update("unknown", new Dummy())
         .testExpression(
             "'%s %s' % (unknown, unknown)",
@@ -863,25 +826,25 @@
 
   @Test
   public void testPercOnObjectInvalidFormat() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .update("obj", createObjWithStr())
         .testIfExactError("invalid argument <str marker> for format pattern %d", "'%d' % obj");
   }
 
   @Test
   public void testDictKeys() throws Exception {
-    new Scenario().testExactOrder("{'a': 1}.keys() + ['b', 'c']", "a", "b", "c");
+    ev.new Scenario().testExactOrder("{'a': 1}.keys() + ['b', 'c']", "a", "b", "c");
   }
 
   @Test
   public void testDictKeysTooManyArgs() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testIfExactError("keys() got unexpected positional argument", "{'a': 1}.keys('abc')");
   }
 
   @Test
   public void testDictKeysTooManyKeyArgs() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testIfExactError(
             "keys() got unexpected keyword argument 'arg'", "{'a': 1}.keys(arg='abc')");
   }
@@ -889,37 +852,36 @@
   @Test
   public void testDictKeysDuplicateKeyArgs() throws Exception {
     // f(a=1, a=2) is caught statically by the resolver.
-    new Scenario()
+    ev.new Scenario()
         .testIfExactError(
             "int() got multiple values for argument 'base'", "int('1', base=10, **dict(base=16))");
   }
 
   @Test
   public void testArgBothPosKey() throws Exception {
-    new Scenario()
+    ev.new Scenario()
         .testIfErrorContains(
             "int() got multiple values for argument 'base'", "int('2', 3, base=3)");
   }
 
   @Test
   public void testStaticNameResolution() throws Exception {
-    new Scenario().testIfErrorContains("name 'foo' is not defined", "[foo for x in []]");
+    ev.new Scenario().testIfErrorContains("name 'foo' is not defined", "[foo for x in []]");
   }
 
   @Test
   public void testExec() throws Exception {
-    StarlarkThread thread =
-        StarlarkThread.builder(Mutability.create("test")).useDefaultSemantics().build();
-    Module module = thread.getGlobals();
-    EvalUtils.exec(
+    ParserInput input =
         ParserInput.fromLines(
             "# a file in the build language",
             "",
-            "x = [1, 2, 'foo', 4] + [1, 2, \"%s%d\" % ('foo', 1)]"),
-        FileOptions.DEFAULT,
-        module,
-        thread);
-    assertThat(thread.getGlobals().lookup("x"))
+            "x = [1, 2, 'foo', 4] + [1, 2, \"%s%d\" % ('foo', 1)]");
+    Module module = Module.create();
+    try (Mutability mu = Mutability.create("test")) {
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+      EvalUtils.exec(input, FileOptions.DEFAULT, module, thread);
+    }
+    assertThat(module.getGlobal("x"))
         .isEqualTo(StarlarkList.of(/*mutability=*/ null, 1, 2, "foo", 4, 1, 2, "foo1"));
   }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/ResolverTest.java b/src/test/java/com/google/devtools/build/lib/syntax/ResolverTest.java
index 0482057..87227f5 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/ResolverTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/ResolverTest.java
@@ -29,8 +29,7 @@
   // Resolves a file using the current options.
   private StarlarkFile resolveFile(String... lines) throws SyntaxError.Exception {
     ParserInput input = ParserInput.fromLines(lines);
-    Module module = Module.createForBuiltins(Starlark.UNIVERSE);
-    return EvalUtils.parseAndValidate(input, options.build(), module);
+    return EvalUtils.parseAndValidate(input, options.build(), Module.create());
   }
 
   // Assertions that parsing and resolution succeeds.
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/StarlarkFlagGuardingTest.java b/src/test/java/com/google/devtools/build/lib/syntax/StarlarkFlagGuardingTest.java
index f1ea610..0083f9d 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/StarlarkFlagGuardingTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/StarlarkFlagGuardingTest.java
@@ -13,6 +13,7 @@
 // limitations under the License.
 package com.google.devtools.build.lib.syntax;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.devtools.build.lib.syntax.StarlarkSemantics.FlagIdentifier;
 import com.google.devtools.build.lib.syntax.util.EvaluationTestCase;
 import net.starlark.java.annot.Param;
@@ -27,7 +28,9 @@
  * parameters with semantic flags.
  */
 @RunWith(JUnit4.class)
-public final class StarlarkFlagGuardingTest extends EvaluationTestCase {
+public final class StarlarkFlagGuardingTest {
+
+  private EvaluationTestCase ev = new EvaluationTestCase();
 
   /** Mock containing exposed methods for flag-guarding tests. */
   @StarlarkBuiltin(name = "Mock", doc = "")
@@ -126,23 +129,23 @@
 
   @Test
   public void testPositionalsOnlyGuardedMethod() throws Exception {
-    new Scenario("--experimental_build_setting_api=true")
+    ev.new Scenario("--experimental_build_setting_api=true")
         .update("mock", new Mock())
         .testEval(
             "mock.positionals_only_method(1, True, 3)", "'positionals_only_method(1, true, 3)'");
 
-    new Scenario("--experimental_build_setting_api=true")
+    ev.new Scenario("--experimental_build_setting_api=true")
         .update("mock", new Mock())
         .testIfErrorContains(
             "in call to positionals_only_method(), parameter 'b' got value of type 'int', want"
                 + " 'bool'",
             "mock.positionals_only_method(1, 3)");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .update("mock", new Mock())
         .testEval("mock.positionals_only_method(1, 3)", "'positionals_only_method(1, false, 3)'");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .update("mock", new Mock())
         .testIfErrorContains(
             "in call to positionals_only_method(), parameter 'c' got value of type 'bool', want"
@@ -152,22 +155,22 @@
 
   @Test
   public void testKeywordOnlyGuardedMethod() throws Exception {
-    new Scenario("--experimental_build_setting_api=true")
+    ev.new Scenario("--experimental_build_setting_api=true")
         .update("mock", new Mock())
         .testEval(
             "mock.keywords_only_method(a=1, b=True, c=3)", "'keywords_only_method(1, true, 3)'");
 
-    new Scenario("--experimental_build_setting_api=true")
+    ev.new Scenario("--experimental_build_setting_api=true")
         .update("mock", new Mock())
         .testIfErrorContains(
             "keywords_only_method() missing 1 required named argument: b",
             "mock.keywords_only_method(a=1, c=3)");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .update("mock", new Mock())
         .testEval("mock.keywords_only_method(a=1, c=3)", "'keywords_only_method(1, false, 3)'");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .update("mock", new Mock())
         .testIfErrorContains(
             "parameter 'b' is experimental and thus unavailable with the current "
@@ -179,13 +182,13 @@
   @Test
   public void testMixedParamsMethod() throws Exception {
     // def mixed_params_method(a, b, c = ?, d = ?)
-    new Scenario("--experimental_build_setting_api=true")
+    ev.new Scenario("--experimental_build_setting_api=true")
         .update("mock", new Mock())
         .testEval(
             "mock.mixed_params_method(1, True, c=3, d=True)",
             "'mixed_params_method(1, true, 3, true)'");
 
-    new Scenario("--experimental_build_setting_api=true")
+    ev.new Scenario("--experimental_build_setting_api=true")
         .update("mock", new Mock())
         .testIfErrorContains(
             // Missing named arguments (d) are not reported
@@ -194,18 +197,18 @@
             "mock.mixed_params_method(1, c=3)");
 
     // def mixed_params_method(a, b disabled = False, c disabled = 3, d = ?)
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .update("mock", new Mock())
         .testEval(
             "mock.mixed_params_method(1, d=True)", "'mixed_params_method(1, false, 3, true)'");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .update("mock", new Mock())
         .testIfErrorContains(
             "mixed_params_method() accepts no more than 1 positional argument but got 2",
             "mock.mixed_params_method(1, True, d=True)");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .update("mock", new Mock())
         .testIfErrorContains(
             "mixed_params_method() accepts no more than 1 positional argument but got 2",
@@ -214,23 +217,23 @@
 
   @Test
   public void testKeywordsMultipleFlags() throws Exception {
-    new Scenario("--experimental_build_setting_api=true", "--incompatible_no_attr_license=false")
+    ev.new Scenario("--experimental_build_setting_api=true", "--incompatible_no_attr_license=false")
         .update("mock", new Mock())
         .testEval(
             "mock.keywords_multiple_flags(a=42, b=True, c=0)",
             "'keywords_multiple_flags(42, true, 0)'");
 
-    new Scenario("--experimental_build_setting_api=true", "--incompatible_no_attr_license=false")
+    ev.new Scenario("--experimental_build_setting_api=true", "--incompatible_no_attr_license=false")
         .update("mock", new Mock())
         .testIfErrorContains(
             "keywords_multiple_flags() missing 2 required named arguments: b, c",
             "mock.keywords_multiple_flags(a=42)");
 
-    new Scenario("--experimental_build_setting_api=false", "--incompatible_no_attr_license=true")
+    ev.new Scenario("--experimental_build_setting_api=false", "--incompatible_no_attr_license=true")
         .update("mock", new Mock())
         .testEval("mock.keywords_multiple_flags(a=42)", "'keywords_multiple_flags(42, false, 3)'");
 
-    new Scenario("--experimental_build_setting_api=false", "--incompatible_no_attr_license=true")
+    ev.new Scenario("--experimental_build_setting_api=false", "--incompatible_no_attr_license=true")
         .update("mock", new Mock())
         .testIfErrorContains(
             "parameter 'b' is deprecated and will be removed soon. It may be "
@@ -243,26 +246,35 @@
     // This test uses an arbitrary experimental flag to verify this functionality. If this
     // experimental flag were to go away, this test may be updated to use any experimental flag.
     // The flag itself is unimportant to the test.
-    predeclare(
-        "GlobalSymbol",
-        FlagGuardedValue.onlyWhenExperimentalFlagIsTrue(
-            FlagIdentifier.EXPERIMENTAL_BUILD_SETTING_API, "foo"));
+
+    // clumsy way to predeclare
+    ev =
+        new EvaluationTestCase() {
+          @Override
+          protected Object newModuleHook(ImmutableMap.Builder<String, Object> predeclared) {
+            predeclared.put(
+                "GlobalSymbol",
+                FlagGuardedValue.onlyWhenExperimentalFlagIsTrue(
+                    FlagIdentifier.EXPERIMENTAL_BUILD_SETTING_API, "foo"));
+            return null; // no client data
+          }
+        };
 
     String errorMessage =
         "GlobalSymbol is experimental and thus unavailable with the current "
             + "flags. It may be enabled by setting --experimental_build_setting_api";
 
-    new Scenario("--experimental_build_setting_api=true")
+    ev.new Scenario("--experimental_build_setting_api=true")
         .setUp("var = GlobalSymbol")
         .testLookup("var", "foo");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .testIfErrorContains(errorMessage, "var = GlobalSymbol");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .testIfErrorContains(errorMessage, "def my_function():", "  var = GlobalSymbol");
 
-    new Scenario("--experimental_build_setting_api=false")
+    ev.new Scenario("--experimental_build_setting_api=false")
         .setUp("GlobalSymbol = 'other'", "var = GlobalSymbol")
         .testLookup("var", "other");
   }
@@ -272,26 +284,34 @@
     // This test uses an arbitrary incompatible flag to verify this functionality. If this
     // incompatible flag were to go away, this test may be updated to use any incompatible flag.
     // The flag itself is unimportant to the test.
-    predeclare(
-        "GlobalSymbol",
-        FlagGuardedValue.onlyWhenIncompatibleFlagIsFalse(
-            FlagIdentifier.INCOMPATIBLE_LINKOPTS_TO_LINKLIBS, "foo"));
+
+    ev =
+        new EvaluationTestCase() {
+          @Override
+          protected Object newModuleHook(ImmutableMap.Builder<String, Object> predeclared) {
+            predeclared.put(
+                "GlobalSymbol",
+                FlagGuardedValue.onlyWhenIncompatibleFlagIsFalse(
+                    FlagIdentifier.INCOMPATIBLE_LINKOPTS_TO_LINKLIBS, "foo"));
+            return null; // no client data
+          }
+        };
 
     String errorMessage =
         "GlobalSymbol is deprecated and will be removed soon. It may be "
             + "temporarily re-enabled by setting --incompatible_linkopts_to_linklibs=false";
 
-    new Scenario("--incompatible_linkopts_to_linklibs=false")
+    ev.new Scenario("--incompatible_linkopts_to_linklibs=false")
         .setUp("var = GlobalSymbol")
         .testLookup("var", "foo");
 
-    new Scenario("--incompatible_linkopts_to_linklibs=true")
+    ev.new Scenario("--incompatible_linkopts_to_linklibs=true")
         .testIfErrorContains(errorMessage, "var = GlobalSymbol");
 
-    new Scenario("--incompatible_linkopts_to_linklibs=true")
+    ev.new Scenario("--incompatible_linkopts_to_linklibs=true")
         .testIfErrorContains(errorMessage, "def my_function():", "  var = GlobalSymbol");
 
-    new Scenario("--incompatible_linkopts_to_linklibs=true")
+    ev.new Scenario("--incompatible_linkopts_to_linklibs=true")
         .setUp("GlobalSymbol = 'other'", "var = GlobalSymbol")
         .testLookup("var", "other");
   }
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadDebuggingTest.java b/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadDebuggingTest.java
index 22bff3b..3553cb9 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadDebuggingTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadDebuggingTest.java
@@ -18,6 +18,7 @@
 import static org.junit.Assert.assertThrows;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.devtools.build.lib.syntax.StarlarkThread.ReadyToPause;
 import com.google.devtools.build.lib.syntax.StarlarkThread.Stepping;
 import org.junit.Test;
@@ -30,22 +31,21 @@
 
   // TODO(adonovan): rewrite these tests at a higher level.
 
-  private static StarlarkThread newStarlarkThread() {
-    Mutability mutability = Mutability.create("test");
-    return StarlarkThread.builder(mutability).useDefaultSemantics().build();
+  private static StarlarkThread newThread() {
+    return new StarlarkThread(Mutability.create("test"), StarlarkSemantics.DEFAULT);
   }
 
-  // Executes the definition of a trivial function f in the specified thread,
-  // and returns the function value.
-  private static StarlarkFunction defineFunc(StarlarkThread thread) throws Exception {
-    Module module = thread.getGlobals();
-    EvalUtils.exec(ParserInput.fromLines("def f(): pass"), FileOptions.DEFAULT, module, thread);
-    return (StarlarkFunction) thread.getGlobals().lookup("f");
+  // Executes the definition of a trivial function f and returns the function value.
+  private static StarlarkFunction defineFunc() throws Exception {
+    Module module = Module.create();
+    EvalUtils.exec(
+        ParserInput.fromLines("def f(): pass"), FileOptions.DEFAULT, module, newThread());
+    return (StarlarkFunction) module.getGlobal("f");
   }
 
   @Test
   public void testListFramesEmptyStack() throws Exception {
-    StarlarkThread thread = newStarlarkThread();
+    StarlarkThread thread = newThread();
     assertThat(Debug.getCallStack(thread)).isEmpty();
     assertThat(thread.getCallStack()).isEmpty();
   }
@@ -80,11 +80,8 @@
         };
 
     // Set up global environment.
-    StarlarkThread thread = newStarlarkThread();
-    Module module = thread.getGlobals();
-    module.put("a", 1);
-    module.put("b", 2);
-    module.put("f", f);
+    Module module =
+        Module.withPredeclared(StarlarkSemantics.DEFAULT, ImmutableMap.of("a", 1, "b", 2, "f", f));
 
     // Execute a small file that calls f.
     ParserInput input =
@@ -93,7 +90,7 @@
                 + "  f()\n"
                 + "g(4, 5, 6)",
             "main.star");
-    EvalUtils.exec(input, FileOptions.DEFAULT, module, thread);
+    EvalUtils.exec(input, FileOptions.DEFAULT, module, newThread());
 
     @SuppressWarnings("unchecked")
     ImmutableList<Debug.Frame> stack = (ImmutableList<Debug.Frame>) result[0];
@@ -127,10 +124,10 @@
 
   @Test
   public void testStepIntoFunction() throws Exception {
-    StarlarkThread thread = newStarlarkThread();
+    StarlarkThread thread = newThread();
 
     ReadyToPause predicate = thread.stepControl(Stepping.INTO);
-    thread.push(defineFunc(thread));
+    thread.push(defineFunc());
 
     assertThat(predicate.test(thread)).isTrue();
   }
@@ -139,7 +136,7 @@
   public void testStepIntoFallsBackToStepOver() {
     // test that when stepping into, we'll fall back to stopping at the next statement in the
     // current frame
-    StarlarkThread thread = newStarlarkThread();
+    StarlarkThread thread = newThread();
 
     ReadyToPause predicate = thread.stepControl(Stepping.INTO);
 
@@ -149,8 +146,8 @@
   @Test
   public void testStepIntoFallsBackToStepOut() throws Exception {
     // test that when stepping into, we'll fall back to stopping when exiting the current frame
-    StarlarkThread thread = newStarlarkThread();
-    thread.push(defineFunc(thread));
+    StarlarkThread thread = newThread();
+    thread.push(defineFunc());
 
     ReadyToPause predicate = thread.stepControl(Stepping.INTO);
     thread.pop();
@@ -160,10 +157,10 @@
 
   @Test
   public void testStepOverFunction() throws Exception {
-    StarlarkThread thread = newStarlarkThread();
+    StarlarkThread thread = newThread();
 
     ReadyToPause predicate = thread.stepControl(Stepping.OVER);
-    thread.push(defineFunc(thread));
+    thread.push(defineFunc());
 
     assertThat(predicate.test(thread)).isFalse();
     thread.pop();
@@ -173,8 +170,8 @@
   @Test
   public void testStepOverFallsBackToStepOut() throws Exception {
     // test that when stepping over, we'll fall back to stopping when exiting the current frame
-    StarlarkThread thread = newStarlarkThread();
-    thread.push(defineFunc(thread));
+    StarlarkThread thread = newThread();
+    thread.push(defineFunc());
 
     ReadyToPause predicate = thread.stepControl(Stepping.OVER);
     thread.pop();
@@ -184,8 +181,8 @@
 
   @Test
   public void testStepOutOfInnerFrame() throws Exception {
-    StarlarkThread thread = newStarlarkThread();
-    thread.push(defineFunc(thread));
+    StarlarkThread thread = newThread();
+    thread.push(defineFunc());
 
     ReadyToPause predicate = thread.stepControl(Stepping.OUT);
 
@@ -196,47 +193,47 @@
 
   @Test
   public void testStepOutOfOutermostFrame() {
-    StarlarkThread thread = newStarlarkThread();
+    StarlarkThread thread = newThread();
 
     assertThat(thread.stepControl(Stepping.OUT)).isNull();
   }
 
   @Test
   public void testStepControlWithNoSteppingReturnsNull() {
-    StarlarkThread thread = newStarlarkThread();
+    StarlarkThread thread = newThread();
 
     assertThat(thread.stepControl(Stepping.NONE)).isNull();
   }
 
   @Test
   public void testEvaluateVariableInScope() throws Exception {
-    StarlarkThread thread = newStarlarkThread();
-    Module module = thread.getGlobals();
-    module.put("a", 1);
+    Module module = Module.withPredeclared(StarlarkSemantics.DEFAULT, ImmutableMap.of("a", 1));
 
+    StarlarkThread thread = newThread();
     Object a = EvalUtils.exec(ParserInput.fromLines("a"), FileOptions.DEFAULT, module, thread);
     assertThat(a).isEqualTo(1);
   }
 
   @Test
   public void testEvaluateVariableNotInScopeFails() throws Exception {
-    StarlarkThread thread = newStarlarkThread();
-    Module module = thread.getGlobals();
-    module.put("a", 1);
+    Module module = Module.create();
 
     SyntaxError.Exception e =
         assertThrows(
             SyntaxError.Exception.class,
-            () -> EvalUtils.exec(ParserInput.fromLines("b"), FileOptions.DEFAULT, module, thread));
+            () ->
+                EvalUtils.exec(
+                    ParserInput.fromLines("b"), FileOptions.DEFAULT, module, newThread()));
 
     assertThat(e).hasMessageThat().isEqualTo("name 'b' is not defined");
   }
 
   @Test
   public void testEvaluateExpressionOnVariableInScope() throws Exception {
-    StarlarkThread thread = newStarlarkThread();
-    Module module = thread.getGlobals();
-    module.put("a", "string");
+    StarlarkThread thread = newThread();
+    Module module =
+        Module.withPredeclared(
+            StarlarkSemantics.DEFAULT, /*predeclared=*/ ImmutableMap.of("a", "string"));
 
     assertThat(
             EvalUtils.exec(
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadTest.java b/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadTest.java
index b688508..41557ef 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadTest.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/StarlarkThreadTest.java
@@ -17,7 +17,6 @@
 import static com.google.common.truth.Truth.assertThat;
 import static org.junit.Assert.assertThrows;
 
-import com.google.common.collect.Sets;
 import com.google.devtools.build.lib.syntax.util.EvaluationTestCase;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -74,128 +73,37 @@
   }
 
   @Test
-  public void testBuilderRequiresSemantics() throws Exception {
-    try (Mutability mut = Mutability.create("test")) {
-      IllegalArgumentException expected =
-          assertThrows(IllegalArgumentException.class, () -> StarlarkThread.builder(mut).build());
-      assertThat(expected)
-          .hasMessageThat()
-          .contains("must call either setSemantics or useDefaultSemantics");
-    }
-  }
-
-  @Test
-  public void testGetVariableNames() throws Exception {
-    StarlarkThread thread;
-    try (Mutability mut = Mutability.create("outer")) {
-      thread =
-          StarlarkThread.builder(mut)
-              .useDefaultSemantics()
-              .setGlobals(Module.createForBuiltins(Starlark.UNIVERSE))
-              .build();
-      thread.getGlobals().put("foo", "bar");
-      thread.getGlobals().put("wiz", 3);
-    }
-
-    assertThat(thread.getVariableNames())
-        .isEqualTo(
-            Sets.newHashSet(
-                "foo",
-                "wiz",
-                "False",
-                "None",
-                "True",
-                "all",
-                "any",
-                "bool",
-                "dict",
-                "dir",
-                "enumerate",
-                "fail",
-                "getattr",
-                "hasattr",
-                "hash",
-                "int",
-                "len",
-                "list",
-                "max",
-                "min",
-                "print",
-                "range",
-                "repr",
-                "reversed",
-                "sorted",
-                "str",
-                "tuple",
-                "type",
-                "zip"));
-  }
-
-  @Test
   public void testBindToNullThrowsException() throws Exception {
     NullPointerException e =
         assertThrows(NullPointerException.class, () -> ev.update("some_name", null));
-    assertThat(e).hasMessageThat().isEqualTo("Module.put(some_name, null)");
-  }
-
-  @Test
-  public void testFrozen() throws Exception {
-    Module module;
-    try (Mutability mutability = Mutability.create("testFrozen")) {
-      // TODO(adonovan): make it simpler to construct a module without a thread,
-      // and move this test to ModuleTest.
-      StarlarkThread thread =
-          StarlarkThread.builder(mutability)
-              .useDefaultSemantics()
-              .setGlobals(Module.createForBuiltins(Starlark.UNIVERSE))
-              .build();
-      module = thread.getGlobals();
-      module.put("x", 1);
-      assertThat(module.lookup("x")).isEqualTo(1);
-      module.put("y", 2);
-      assertThat(module.lookup("y")).isEqualTo(2);
-      assertThat(module.lookup("x")).isEqualTo(1);
-      module.put("x", 3);
-      assertThat(module.lookup("x")).isEqualTo(3);
-    }
-
-    // This update to an existing variable should fail because the environment was frozen.
-    EvalException ex = assertThrows(EvalException.class, () -> module.put("x", 4));
-    assertThat(ex).hasMessageThat().isEqualTo("trying to mutate a frozen module");
-
-    // This update to a new variable should also fail because the environment was frozen.
-    ex = assertThrows(EvalException.class, () -> module.put("newvar", 5));
-    assertThat(ex).hasMessageThat().isEqualTo("trying to mutate a frozen module");
+    assertThat(e).hasMessageThat().isEqualTo("Module.setGlobal(some_name, null)");
   }
 
   @Test
   public void testBuiltinsCanBeShadowed() throws Exception {
+    Module module = Module.create();
     try (Mutability mu = Mutability.create("test")) {
-      StarlarkThread thread = StarlarkThread.builder(mu).useDefaultSemantics().build();
-      EvalUtils.exec(
-          ParserInput.fromLines("True = 123"), FileOptions.DEFAULT, thread.getGlobals(), thread);
-      assertThat(thread.getGlobals().lookup("True")).isEqualTo(123);
+      StarlarkThread thread = new StarlarkThread(mu, StarlarkSemantics.DEFAULT);
+      EvalUtils.exec(ParserInput.fromLines("True = 123"), FileOptions.DEFAULT, module, thread);
     }
+    assertThat(module.getGlobal("True")).isEqualTo(123);
   }
 
   @Test
   public void testVariableIsReferencedBeforeAssignment() throws Exception {
-    try (Mutability mu = Mutability.create("test")) {
-      StarlarkThread thread = StarlarkThread.builder(mu).useDefaultSemantics().build();
-      Module module = thread.getGlobals();
-      module.put("global_var", 666);
-      EvalUtils.exec(
-          ParserInput.fromLines(
-              "def foo(x): x += global_var; global_var = 36; return x", //
-              "foo(1)"),
-          FileOptions.DEFAULT,
-          module,
-          thread);
-      throw new AssertionError("failed to fail");
-    } catch (EvalExceptionWithStackTrace e) {
-      assertThat(e)
-          .hasMessageThat()
-          .contains("local variable 'global_var' is referenced before assignment.");
-    }
+    ev.new Scenario()
+        .testIfErrorContains(
+            "local variable 'y' is referenced before assignment",
+            "y = 1", // bind => y is global
+            "def foo(x):",
+            "  x += y", // fwd ref to local y
+            "  y = 2", // binding => y is local
+            "  return x",
+            "foo(1)");
+    ev.new Scenario()
+        .testIfErrorContains(
+            "global variable 'len' is referenced before assignment",
+            "print(len)", // fwd ref to global len
+            "len = 1"); // binding => len is local
   }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/syntax/util/EvaluationTestCase.java b/src/test/java/com/google/devtools/build/lib/syntax/util/EvaluationTestCase.java
index 0203bfe..b1c56bd 100644
--- a/src/test/java/com/google/devtools/build/lib/syntax/util/EvaluationTestCase.java
+++ b/src/test/java/com/google/devtools/build/lib/syntax/util/EvaluationTestCase.java
@@ -36,10 +36,8 @@
 import com.google.devtools.build.lib.syntax.SyntaxError;
 import com.google.devtools.common.options.Options;
 import com.google.devtools.common.options.OptionsParsingException;
-import java.util.HashMap;
 import java.util.LinkedList;
 import java.util.List;
-import java.util.Map;
 
 /** Helper class for tests that evaluate Starlark code. */
 // TODO(adonovan): stop extending this class. Prefer composition over inheritance.
@@ -54,58 +52,40 @@
 // predeclared env + semantics.
 // For the most part, the predeclared env doesn't vary across a suite,
 // so it could be a constructor parameter.
+//
+// TODO(adonovan): this helper class might be somewhat handy for testing core Starlark, but its
+// widespread use in tests of Bazel features greatly hinders the improvement of Bazel's loading
+// phase. The existence of tests based on this class forces Bazel to continue support scenarios in
+// which the test creates the environment, the threads, and so on, when these should be
+// implemenation details of the loading phase. Instead, the lib.packages should present an API in
+// which the client provides files, flags, and arguments like a command-line tool, and all our tests
+// should be ported to use that API.
 public class EvaluationTestCase {
   private EventCollectionApparatus eventCollectionApparatus =
       new EventCollectionApparatus(EventKind.ALL_EVENTS);
 
-  private final Map<String, Object> extraPredeclared = new HashMap<>();
-  private StarlarkThread thread = newStarlarkThread(StarlarkSemantics.DEFAULT);
-
-  // Adds a binding to the predeclared environment.
-  protected final void predeclare(String name, Object value) {
-    extraPredeclared.put(name, value);
-  }
+  private StarlarkSemantics semantics = StarlarkSemantics.DEFAULT;
+  private StarlarkThread thread = null; // created lazily by getStarlarkThread
+  private Module module = null; // created lazily by getModule
 
   /**
-   * Returns a new thread using the semantics set by setSemantics(), the predeclared environment of
-   * StarlarkModules and prior calls to predeclared(), and a new mutability. Overridden by
-   * subclasses.
-   */
-  // TODO(adonovan): stop using inheritance.
-  protected StarlarkThread newStarlarkThread(StarlarkSemantics semantics) {
-    ImmutableMap.Builder<String, Object> envBuilder = ImmutableMap.builder();
-    StarlarkModules.addStarlarkGlobalsToBuilder(envBuilder); // TODO(adonovan): break bad dependency
-    envBuilder.putAll(extraPredeclared);
-
-    StarlarkThread thread =
-        StarlarkThread.builder(Mutability.create("test"))
-            .setGlobals(Module.createForBuiltins(envBuilder.build()))
-            .setSemantics(semantics)
-            .build();
-    thread.setPrintHandler(Event.makeDebugPrintHandler(getEventHandler()));
-    return thread;
-  }
-
-  /**
-   * Parses the semantics flags and updates the semantics used for subsequent evaluations. Also
-   * reinitializes the thread.
+   * Parses the semantics flags and updates the semantics used to filter predeclared bindings, and
+   * carried by subsequently created threads. Causes a new StarlarkThread and Module to be created
+   * when next needed.
    */
   public final void setSemantics(String... options) throws OptionsParsingException {
-    StarlarkSemantics semantics =
+    this.semantics =
         Options.parse(StarlarkSemanticsOptions.class, options).getOptions().toStarlarkSemantics();
 
-    // Re-initialize the thread with the new semantics.
-    this.thread = newStarlarkThread(semantics);
+    // Re-initialize the thread and module with the new semantics when needed.
+    this.thread = null;
+    this.module = null;
   }
 
   public ExtendedEventHandler getEventHandler() {
     return eventCollectionApparatus.reporter();
   }
 
-  public StarlarkThread getStarlarkThread() {
-    return thread;
-  }
-
   // TODO(adonovan): don't let subclasses inherit vaguely specified "helpers".
   // Separate all the tests clearly into tests of the scanner, parser, resolver,
   // and evaluation.
@@ -115,28 +95,65 @@
     return Expression.parse(ParserInput.fromLines(lines));
   }
 
-  /** Updates a binding in the module associated with the thread. */
+  /** Updates a global binding in the module. */
+  // TODO(adonovan): rename setGlobal.
   public EvaluationTestCase update(String varname, Object value) throws Exception {
-    thread.getGlobals().put(varname, value);
+    getModule().setGlobal(varname, value);
     return this;
   }
 
-  /** Returns the value of a binding in the module associated with the thread. */
+  /** Returns the value of a global binding in the module. */
+  // TODO(adonovan): rename getGlobal.
   public Object lookup(String varname) throws Exception {
-    return thread.getGlobals().lookup(varname);
+    return getModule().getGlobal(varname);
   }
 
   /** Joins the lines, parses them as an expression, and evaluates it. */
   public final Object eval(String... lines) throws Exception {
     ParserInput input = ParserInput.fromLines(lines);
-    return EvalUtils.eval(input, FileOptions.DEFAULT, thread.getGlobals(), thread);
+    return EvalUtils.eval(input, FileOptions.DEFAULT, getModule(), getStarlarkThread());
   }
 
   /** Joins the lines, parses them as a file, and executes it. */
   public final void exec(String... lines)
       throws SyntaxError.Exception, EvalException, InterruptedException {
     ParserInput input = ParserInput.fromLines(lines);
-    EvalUtils.exec(input, FileOptions.DEFAULT, thread.getGlobals(), thread);
+    EvalUtils.exec(input, FileOptions.DEFAULT, getModule(), getStarlarkThread());
+  }
+
+  // A hook for subclasses to alter a newly created thread,
+  // e.g. by inserting thread-local values.
+  protected void newThreadHook(StarlarkThread thread) {}
+
+  // A hook for subclasses to alter the created module.
+  // Implementations may add to the predeclared environment,
+  // and return the module's client data value.
+  protected Object newModuleHook(ImmutableMap.Builder<String, Object> predeclared) {
+    StarlarkModules.addStarlarkGlobalsToBuilder(
+        predeclared); // TODO(adonovan): break bad dependency
+    return null; // no client data
+  }
+
+  public StarlarkThread getStarlarkThread() {
+    if (this.thread == null) {
+      Mutability mu = Mutability.create("test");
+      StarlarkThread thread = new StarlarkThread(mu, semantics);
+      thread.setPrintHandler(Event.makeDebugPrintHandler(getEventHandler()));
+      newThreadHook(thread);
+      this.thread = thread;
+    }
+    return this.thread;
+  }
+
+  public Module getModule() {
+    if (this.module == null) {
+      ImmutableMap.Builder<String, Object> predeclared = ImmutableMap.builder();
+      Object clientData = newModuleHook(predeclared);
+      Module module = Module.withPredeclared(semantics, predeclared.build());
+      module.setClientData(clientData);
+      this.module = module;
+    }
+    return this.module;
   }
 
   public void checkEvalError(String msg, String... input) throws Exception {
@@ -286,7 +303,7 @@
      *
      * @param exactMatch whether the error message must be identical to the expected error.
      */
-    protected Testable errorTestable(
+    private Testable errorTestable(
         final boolean exactMatch, final String error, final String... lines) {
       return new Testable() {
         @Override
@@ -304,7 +321,7 @@
      * Creates a Testable that checks whether the value of the expression is a sequence containing
      * the expected elements.
      */
-    protected Testable collectionTestable(final String src, final Object... expected) {
+    private Testable collectionTestable(final String src, final Object... expected) {
       return new Testable() {
         @Override
         public void run() throws Exception {
@@ -322,7 +339,7 @@
      * @param expectedIsExpression Signals whether {@code expected} is an object or an expression
      * @return An instance of Testable that runs the comparison
      */
-    protected Testable createComparisonTestable(
+    private Testable createComparisonTestable(
         final String src, final Object expected, final boolean expectedIsExpression) {
       return new Testable() {
         @Override
@@ -344,11 +361,12 @@
     /**
      * Creates a Testable that looks up the given variable and compares its value to the expected
      * value
+     *
      * @param name
      * @param expected
      * @return An instance of Testable that does both lookup and comparison
      */
-    protected Testable createLookUpTestable(final String name, final Object expected) {
+    private Testable createLookUpTestable(final String name, final Object expected) {
       return new Testable() {
         @Override
         public void run() throws Exception {