OP here. This is my final solution (which actually solves a lot more than I asked about).
I use the same implementation for Threads in both test and production, but pass in different TaskSchedulers:
public class Threads
{
    private readonly TaskScheduler _executeScheduler;
    private readonly TaskScheduler _continueScheduler;
    public Threads(TaskScheduler executeScheduler, TaskScheduler continueScheduler)
    {
        _executeScheduler = executeScheduler;
        _continueScheduler = continueScheduler;
    }
    public TaskContinuation<TRet> StartNew<TRet>(Func<TRet> func)
    {
        var task = Task.Factory.StartNew(func, CancellationToken.None, TaskCreationOptions.None, _executeScheduler);
        return new TaskContinuation<TRet>(task, _continueScheduler);
    }
}
I wrap the Task in a TaskContinuation class in order to be able to specify TaskScheduler for the ContinueWith() call.
public class TaskContinuation<TRet>
{
    private readonly Task<TRet> _task;
    private readonly TaskScheduler _scheduler;
    public TaskContinuation(Task<TRet> task, TaskScheduler scheduler)
    {
        _task = task;
        _scheduler = scheduler;
    }
    public void ContinueWith(Action<Task<TRet>> func)
    {
        _task.ContinueWith(func, _scheduler);
    }
}
I create my custom TaskScheduler that dispatches the action on the thread that scheduler was created on:
public class CurrentThreadScheduler : TaskScheduler
{
    private readonly Dispatcher _dispatcher;
    public CurrentThreadScheduler()
    {
        _dispatcher = Dispatcher.CurrentDispatcher;
    }
    protected override void QueueTask(Task task)
    {
        _dispatcher.BeginInvoke(new Func<bool>(() => TryExecuteTask(task)));
    }
    protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
    {
        return true;
    }
    protected override IEnumerable<Task> GetScheduledTasks()
    {
        return Enumerable.Empty<Task>();
    }
}
Now I can specify the behaviour by passing in different TaskSchedulers to the Threads constructor.
new Threads(TaskScheduler.Default, TaskScheduler.FromCurrentSynchronizationContext()); // Production
new Threads(TaskScheduler.Default, new CurrentThreadScheduler()); // Let the tests use background threads
new Threads(new CurrentThreadScheduler(), new CurrentThreadScheduler()); // No threads, all synchronous
Finally, since the event loop doesn't run automatically in my unit test, I have to execute it manually. Whenever I need to wait for a background operation to complete I execute the following (from the main thread):
DispatcherHelper.DoEvents();
The DispatcherHelper can be found here.