如何使这个SwingWorker代码可测试

16

考虑以下代码:

public void actionPerformed(ActionEvent e) {
    setEnabled(false);
    new SwingWorker<File, Void>() {

        private String location = url.getText();

        @Override
        protected File doInBackground() throws Exception {
            File file = new File("out.txt");
            Writer writer = null;
            try {
                writer = new FileWriter(file);
                creator.write(location, writer);
            } finally {
                if (writer != null) {
                    writer.close();
                }
            }
            return file;
        }

        @Override
        protected void done() {
            setEnabled(true);
            try {
                File file = get();
                JOptionPane.showMessageDialog(FileInputFrame.this,
                    "File has been retrieved and saved to:\n"
                    + file.getAbsolutePath());
                Desktop.getDesktop().open(file);
            } catch (InterruptedException ex) {
                logger.log(Level.INFO, "Thread interupted, process aborting.", ex);
                Thread.currentThread().interrupt();
            } catch (ExecutionException ex) {
                Throwable cause = ex.getCause() == null ? ex : ex.getCause();
                logger.log(Level.SEVERE, "An exception occurred that was "
                    + "not supposed to happen.", cause);
                JOptionPane.showMessageDialog(FileInputFrame.this, "Error: "
                    + cause.getClass().getSimpleName() + " "
                    + cause.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
            } catch (IOException ex) {
                logger.log(Level.INFO, "Unable to open file for viewing.", ex);
            }
        }
    }.execute();

url是一个JTextField,而'creator'则是一个注入的接口用于写文件(因此这部分内容处于测试之下)。文件写入位置是故意硬编码的,因为它旨在作为示例。而java.util.logging仅被用来避免外部依赖。

你会如何划分块以使其可单元测试(包括放弃SwingWorker如果需要的话,但至少要替换这里使用的功能)?

我认为,doInBackground基本上是正确的。基本机制是创建一个写入器并关闭它,这几乎太简单了以至于不需要测试,真正的工作处于测试之下。然而,done方法有些问题,包括与父类中的actionPerformed方法的耦合以及协调按钮的启用和禁用。

然而,拆开这个方法并不明显。注入某种SwingWorkerFactory会使捕获GUI字段变得更加困难(很难看出它将如何成为设计改进)。JOptionPane和Desktop拥有所有“优点”的单例模式,并且异常处理使得很难轻松地封装get方法。

那么,有什么好的解决方案可以使这段代码可测试吗?


如何将代码划分为单元测试的块(包括放弃SwingWorker并替换其功能,至少在这里使用的方式)?doInBackground方法基本上正确。基本机制是创建一个写入器并关闭它,这几乎太简单了以至于不需要测试,真正的工作处于测试之下。但是,done方法有些问题,包括与父类中的actionPerformed方法的耦合以及协调按钮的启用和禁用。然而,拆开这个方法并不明显。注入某种SwingWorkerFactory会使捕获GUI字段变得更加困难,并且很难看出它将如何成为设计改进。JOptionPane和Desktop拥有所有“优点”的单例模式,并且异常处理使得很难轻松地封装get方法。因此,有什么好的解决方案可以使这段代码可测试吗?


重新格式化的代码,请在不正确的情况下恢复。 - trashgod
1
不是完整的答案:但如果你喜欢高质量的代码,就不要接近SwingWorker。通常情况下,将事物分解开来。当你有一个使用静态/单例的API时,引入一个接口,其中一个实现使用“真正”的静态API,另一个用于模拟(可能还有一个用于审计)。 - Tom Hawtin - tackline
@Tom,如果你有时间写出SwingWorker的另一种设计方案的大纲(或者你知道更好的替代实现),那将不胜感激。 - Yishai
3个回答

11

个人认为,这对于匿名类来说太复杂了。我的做法是将匿名类重构为类似于以下方式的代码:

public class FileWriterWorker extends SwingWorker<File, Void> {
    private final String location;
    private final Response target;
    private final Object creator;

    public FileWriterWorker(Object creator, String location, Response target) {
        this.creator = creator;
        this.location = location;
        this.target = target;
    }

    @Override
    protected File doInBackground() throws Exception {
        File file = new File("out.txt");
        Writer writer = null;
        try {
            writer = new FileWriter(file);
            creator.write(location, writer);
        }
        finally {
            if (writer != null) {
                writer.close();
            }
        }
        return file;
    }

    @Override
    protected void done() {
        try {
            File file = get();
            target.success(file);
        }
        catch (InterruptedException ex) {
            target.failure(new BackgroundException(ex));
        }
        catch (ExecutionException ex) {
            target.failure(new BackgroundException(ex));
        }
    }

    public interface Response {
        void success(File f);
        void failure(BackgroundException ex);
    }

    public class BackgroundException extends Exception {
        public BackgroundException(Throwable cause) {
            super(cause);
        }
    }
}

这样可以独立于GUI测试文件写入功能。

然后,actionPerformed 变成了这样:

public void actionPerformed(ActionEvent e) {
    setEnabled(false);
    Object creator;
    new FileWriterWorker(creator, url.getText(), new FileWriterWorker.Response() {
        @Override
        public void failure(FileWriterWorker.BackgroundException ex) {
            setEnabled(true);
            Throwable bgCause = ex.getCause();
            if (bgCause instanceof InterruptedException) {
                logger.log(Level.INFO, "Thread interupted, process aborting.", bgCause);
                Thread.currentThread().interrupt();
            }
            else if (cause instanceof ExecutionException) {
                Throwable cause = bgCause.getCause() == null ? bgCause : bgCause.getCause();
                logger.log(Level.SEVERE, "An exception occurred that was "
                    + "not supposed to happen.", cause);
                JOptionPane.showMessageDialog(FileInputFrame.this, "Error: "
                    + cause.getClass().getSimpleName() + " "
                    + cause.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
            }
        }

        @Override
        public void success(File f) {
            setEnabled(true);
            JOptionPane.showMessageDialog(FileInputFrame.this,
                "File has been retrieved and saved to:\n"
                + file.getAbsolutePath());
            try {
                Desktop.getDesktop().open(file);
            }
            catch (IOException iOException) {
                logger.log(Level.INFO, "Unable to open file for viewing.", ex);
            }
        }
    }).execute();
}

此外,FileWriterWorker.Response 的实例可以被赋值给一个变量,并独立于 FileWriterWorker 进行测试。


这段代码片段中有一些不错的想法/习语。谢谢,我会再好好琢磨一下。 - Yishai
我就是喜欢这个。 - gdbj

10

当前的实现将线程问题、UI和文件写入耦合在一起,正如您发现的那样,这种耦合使得难以单独测试每个组件。

这是一个相当长的响应,但归结起来,就是从当前实现中提取出这三个关注点,并将其放入具有定义接口的单独类中。

因子化应用逻辑

首先,专注于核心应用逻辑,并将其移入单独的类/接口。接口可以更轻松地进行模拟,并使用其他Swing线程框架。分离意味着您可以完全独立地测试应用程序逻辑,而不受其他问题的影响。

interface FileWriter
{
    void writeFile(File outputFile, String location, Creator creator)
         throws IOException;
    // you could also create your own exception type to avoid the checked exception.

    // a request object allows all the params to be encapsulated in one object.
    // this makes chaining services easier. See later.
    void writeFile(FileWriteRequest writeRequest); 
}

class FileWriteRequest
{
    File outputFile;
    String location;
    Creator creator;
    // constructor, getters etc..
}


class DefualtFileWriter implements FileWriter
{
    // this is basically the code from doInBackground()
    public File writeFile(File outputFile, String location, Creator creator)
       throws IOException 
    {
            Writer writer = null;
            try {
                writer = new FileWriter(outputFile);
                creator.write(location, writer);
            } finally {
                if (writer != null) {
                    writer.close();
                }
            }
            return file;
    }   
    public void writeFile(FileWriterRequest request) {
         writeFile(request.outputFile, request.location, request.creator);
    }
}

分离UI

现在应用程序逻辑已经分离,我们接着将成功和错误处理分离出来。这意味着UI可以在不进行文件写入的情况下进行测试。特别是,可以在不需要引发这些错误的情况下测试错误处理。在这里,错误非常简单,但通常有些错误很难引发。通过分离错误处理,还有机会进行重用,或替换如何处理错误。例如,稍后使用JXErrorPane

interface FileWriterHandler {
     void done();
     void handleFileWritten(File file);
     void handleFileWriteError(Throwable t);
}  

class FileWriterJOptionPaneOpenDesktopHandler implements FileWriterHandler
{
   private JFrame owner;
   private JComponent enableMe;

   public void done() { enableMe.setEnabled(true); }

   public void handleFileWritten(File file) {
       try {
         JOptionPane.showMessageDialog(owner,
                    "File has been retrieved and saved to:\n"
                    + file.getAbsolutePath());
         Desktop.getDesktop().open(file);
       }
       catch (IOException ex) {
           handleDesktopOpenError(ex);
       }
   }

   public void handleDesktopOpenError(IOException ex) {
        logger.log(Level.INFO, "Unable to open file for viewing.", ex);        
   }

   public void handleFileWriteError(Throwable t) {
        if (t instanceof InterruptedException) {
                logger.log(Level.INFO, "Thread interupted, process aborting.", ex);  
                // no point interrupting the EDT thread
        }
       else if (t instanceof ExecutionException) {
           Throwable cause = ex.getCause() == null ? ex : ex.getCause();
           handleGeneralError(cause);
       }
       else
         handleGeneralError(t);
   }

   public void handleGeneralError(Throwable cause) {
        logger.log(Level.SEVERE, "An exception occurred that was "
                    + "not supposed to happen.", cause);
        JOptionPane.showMessageDialog(owner, "Error: "
                    + cause.getClass().getSimpleName() + " "
                    + cause.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
   }
}

分离出线程

最后,我们还可以使用FileWriterService将线程问题分离出来。使用上面的FileWriteRequest可以使编码变得更简单。

interface FileWriterService
{
   // rather than have separate parms for file writing, it is
   void handleWriteRequest(FileWriteRequest request, FileWriter writer, FileWriterHandler handler);
}

class SwingWorkerFileWriterService 
   implements FileWriterService
{
   void handleWriteRequest(FileWriteRequest request, FileWriter writer, FileWriterHandler handler) {
       Worker worker = new Worker(request, fileWriter, fileWriterHandler);
       worker.execute();
   }

   static class Worker extends SwingWorker<File,Void> {
        // set in constructor
        private FileWriter fileWriter;
        private FileWriterHandler fileWriterHandler;
        private FileWriterRequest fileWriterRequest;

        protected File doInBackground() {
            return fileWriter.writeFile(fileWriterRequest);
        }
        protected void done() {
            fileWriterHandler.done();
            try
            {
                File f = get();
                fileWriterHandler.handleFileWritten(f);
            }
            catch (Exception ex)
            {                   
                // you could also specifically unwrap the ExecutorException here, since that
                // is specific to the service implementation using SwingWorker/Executors.
                fileWriterHandler.handleFileError(ex);
            }
        }
   }

}

每个系统部分都可以单独进行测试 - 应用逻辑、演示(成功和错误处理)以及线程实现也是一个独立的关注点。
这可能看起来像很多接口,但实现大多数是从原始代码中剪切和粘贴而来。这些接口提供了所需的分离,使这些类可测试。
我不太喜欢SwingWorker,因此将它们保留在接口后面有助于将其产生的混乱保持在代码之外。它还允许您使用不同的实现来实现单独的UI/后台线程。例如,要使用Spin,您只需要提供FileWriterService的新实现即可。

-1

简单解决方案:一个简单的计时器最好;您启动计时器,启动 actionPerformed ,在超时时按钮必须启用等。

这里是一个使用 java.util.Timer 的非常小的示例:

package goodies;

import java.util.Timer;
import java.util.TimerTask;
import javax.swing.JButton;

public class SWTest
{
  static class WithButton
  {
    JButton button = new JButton();

    class Worker extends javax.swing.SwingWorker<Void, Void>
    {
      @Override
      protected Void doInBackground() throws Exception
      {
        synchronized (this)
        {
          wait(4000);
        }
        return null;
      }

      @Override
      protected void done()
      {
        button.setEnabled(true);
      }
    }

    void startWorker()
    {
      Worker work = new Worker();
      work.execute();
    }
  }

    public static void main(String[] args)
    {
      final WithButton with;
      TimerTask verif;

      with = new WithButton();
      with.button.setEnabled(false);
      Timer tim = new Timer();
      verif = new java.util.TimerTask()
      {
        @Override
        public void run()
        {
          if (!with.button.isEnabled())
            System.out.println("BAD");
          else
            System.out.println("GOOD");
          System.exit(0);
        }};
      tim.schedule(verif, 5000);
      with.startWorker();
    }
}

所谓专家解决方案:Swing Worker是一个RunnableFuture,其中包含一个嵌入在Callable中的FutureTask,因此您可以使用自己的执行器来启动它(RunnableFuture)。为此,您需要一个具有名称类而不是匿名类的SwingWorker。有了自己的执行器和名称类,您可以进行任何想要的测试,所谓的专家说。


1
我不明白为什么你会使用一个计时器来重新启用按钮,如果在那段时间内进程可能尚未完成,或者保持按钮不必要地禁用。 - Yishai
抱歉,我的英语不太好。不是“必须启用”,而是“已启用”,我想是这样的。我会在我的答案中加入一些Java示例代码。希望更好理解。 - Istao

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接